commit 54e1e83bfe57e166800bb5fb050faea698379205 Author: 2ro <17595647+2ro@users.noreply.github.com> Date: Wed Jun 17 22:22:21 2026 -0400 Goblin — Build 98 diff --git a/.github/actions/fetch-nym/action.yml b/.github/actions/fetch-nym/action.yml new file mode 100644 index 00000000..1e8faf4e --- /dev/null +++ b/.github/actions/fetch-nym/action.yml @@ -0,0 +1,23 @@ +name: Fetch patched nym SDK +description: > + Clone the patched nym workspace from our own mirror + (git.us-ea.st/GRIN/nym, branch `goblin` = upstream nymtech/nym @ b6eb391 + + Goblin's Android webpki-roots patch) into ../nym, so the + `nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }` dependency resolves. + Self-hosted: no upstream-GitHub fetch and no patch-apply step. + +runs: + using: composite + steps: + - name: Clone patched nym + shell: bash + run: | + set -euo pipefail + DEST="$(dirname "$GITHUB_WORKSPACE")/nym" + if [ -e "$DEST/sdk/rust/nym-sdk/Cargo.toml" ]; then + echo "nym already present at $DEST" + exit 0 + fi + rm -rf "$DEST" + git clone --branch goblin --depth 1 https://git.us-ea.st/GRIN/nym.git "$DEST" + echo "nym cloned from GRIN/nym@goblin -> $DEST" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..46026398 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,43 @@ +name: Build +on: [push, pull_request] + +# aws-lc-sys (pulled in by nym-sdk) builds AWS-LC, which needs NASM on native +# Windows. Use the prebuilt NASM objects the crate ships so the runner doesn't +# need NASM installed; harmless on Linux/macOS. +env: + AWS_LC_SYS_PREBUILT_NASM: 1 + +jobs: + linux: + name: Linux Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + # nym-sdk is a path dep on ../nym; materialize it before building. + - uses: ./.github/actions/fetch-nym + - name: Release build + run: cargo build --release + + windows: + name: Windows Build + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: ./.github/actions/fetch-nym + - name: Release build + run: cargo build --release + + macos: + name: MacOS Build + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: ./.github/actions/fetch-nym + - name: Release build + run: cargo build --release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..40bb8625 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,145 @@ +# Release builds on native runners — one per platform, no cross-compilation +# (nokhwa's camera backends want each platform's own SDK; see NEXT-STEPS judgment). +# +# Manually triggered (Actions → Release → Run workflow) against an existing tag +# until a run has been validated end-to-end; then this can move to a tag trigger. +# Android is built locally via scripts/android.sh for now — the gradle `ci` +# flavor expects maven credentials this repository does not carry. +name: Release + +on: + # macOS is DEFERRED until Linux/Windows/Android are polished — so this is + # manual-dispatch only for now (no auto-build on release publish). When macOS + # is back on the table, re-add `release: { types: [published] }` here and the + # macOS job will attach a universal build to each release automatically. + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build and upload artifacts to (e.g. build27)" + required: true + +permissions: + contents: write + +env: + TAG: ${{ inputs.tag || github.event.release.tag_name }} + # aws-lc-sys (via nym-sdk) needs NASM on native Windows; use its prebuilt NASM. + AWS_LC_SYS_PREBUILT_NASM: 1 + +jobs: + linux: + name: Linux x86_64 + runs-on: ubuntu-latest + # Built locally and uploaded with the release; only run on manual dispatch. + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.event.release.tag_name }} + submodules: recursive + - uses: ./.github/actions/fetch-nym + - name: Build + shell: bash + run: GOBLIN_BUILD="${TAG#build}" cargo build --release + - name: Package + run: | + tar -C target/release -czf "goblin-$TAG-linux-x86_64.tar.gz" goblin + sha256sum "goblin-$TAG-linux-x86_64.tar.gz" > "goblin-$TAG-linux-x86_64-sha256sum.txt" + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.event.release.tag_name }} + files: | + goblin-${{ inputs.tag || github.event.release.tag_name }}-linux-x86_64.tar.gz + goblin-${{ inputs.tag || github.event.release.tag_name }}-linux-x86_64-sha256sum.txt + + windows: + name: Windows x86_64 (MSVC) + runs-on: windows-latest + # Built locally and uploaded with the release; only run on manual dispatch. + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.event.release.tag_name }} + submodules: recursive + - uses: ./.github/actions/fetch-nym + - name: Build + shell: bash + run: GOBLIN_BUILD="${TAG#build}" cargo build --release + - name: Build MSI installer (cargo-wix / WiX 3 — same packaging as GRIM) + shell: pwsh + run: | + # The .msi is built from wix/main.wxs (the cargo-wix default template: + # WixUI_Minimal + launch-after-install), so `cargo wix` wires up the + # WixUI/WixUtil extensions, cultures and CargoTargetBinDir for us. The + # installer + shortcuts + Add/Remove-Programs entry carry wix/Product.ico + # (the yellow Goblin icon). --no-build reuses the release exe above so the + # embedded GOBLIN_BUILD number is preserved. + cargo install cargo-wix --locked + $wix = Get-ChildItem 'C:\Program Files (x86)' -Directory -Filter 'WiX Toolset v3*' -ErrorAction SilentlyContinue | Select-Object -Last 1 + if (-not $wix) { + choco install wixtoolset --no-progress -y | Out-Null + $wix = Get-ChildItem 'C:\Program Files (x86)' -Directory -Filter 'WiX Toolset v3*' | Select-Object -Last 1 + } + $env:WIX = "$($wix.FullName)\" + $env:PATH = "$($wix.FullName)\bin;$env:PATH" + $msi = "goblin-$env:TAG-win-x86_64.msi" + cargo wix --no-build --nocapture -o "$msi" + if ($LASTEXITCODE -ne 0 -or -not (Test-Path "$msi")) { throw "cargo wix failed to produce $msi" } + (Get-FileHash "$msi" -Algorithm SHA256).Hash.ToLower() + " $msi" | Out-File -Encoding ascii "goblin-$env:TAG-win-x86_64-msi-sha256sum.txt" + - name: Package portable zip + shell: bash + run: | + 7z a "goblin-$TAG-win-x86_64.zip" ./target/release/goblin.exe + sha256sum "goblin-$TAG-win-x86_64.zip" > "goblin-$TAG-win-x86_64-sha256sum.txt" + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.event.release.tag_name }} + files: | + goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64.msi + goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64-msi-sha256sum.txt + goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64.zip + goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64-sha256sum.txt + + macos: + name: macOS universal + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.event.release.tag_name }} + submodules: recursive + - uses: ./.github/actions/fetch-nym + - name: Build both architectures + run: | + export GOBLIN_BUILD="${TAG#build}" + rustup target add aarch64-apple-darwin x86_64-apple-darwin + cargo build --release --target aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + - name: Universal binary into Goblin.app bundle + run: | + # Combine both arches into one universal Mach-O and drop it into the + # app bundle's executable slot (CFBundleExecutable=goblin). + lipo -create -output goblin \ + target/aarch64-apple-darwin/release/goblin \ + target/x86_64-apple-darwin/release/goblin + cp goblin macos/Goblin.app/Contents/MacOS/goblin + chmod +x macos/Goblin.app/Contents/MacOS/goblin + # Drop the placeholder that kept the empty dir tracked in git. + rm -f macos/Goblin.app/Contents/MacOS/.gitignore + # Ad-hoc sign (no Apple cert in CI). REQUIRED on Apple Silicon: lipo + # strips the per-arch signatures cargo/ld add, and an unsigned arm64 + # Mach-O is killed by the OS. Ad-hoc gives a valid (if unidentified) + # signature; users still right-click → Open past Gatekeeper. + codesign --force --sign - macos/Goblin.app/Contents/MacOS/goblin + codesign --force --sign - macos/Goblin.app + # ditto is the macOS-correct way to zip an .app (preserves the bundle + # layout, symlinks and permissions; plain `zip` mangles bundles). + ditto -c -k --keepParent macos/Goblin.app "goblin-$TAG-macos-universal.zip" + shasum -a 256 "goblin-$TAG-macos-universal.zip" > "goblin-$TAG-macos-universal-sha256sum.txt" + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.event.release.tag_name }} + files: | + goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal.zip + goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal-sha256sum.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ff217dc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +*.iml +android/build +android/.idea +android/.gradle +android/local.properties +android/keystore +android/keystore.asc +android/keystore.properties +android/*.apk +android/*sha256sum.txt +/.idea +.DS_Store +/captures +.externalNativeBuild +.cxx +*.so +target +.cargo/ +app/src/main/jniLibs +macos/cert.pem +linux/Goblin.AppDir/AppRun +.intentionally-empty-file.o +Cargo.toml-e +screenshots/ +# GRIM-canonical build toolchains fetched by scripts/toolchain.sh +.toolchains/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..3ca683f4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,7 @@ +[submodule "node"] + path = node + url = https://code.gri.mw/ardocrat/node +[submodule "wallet"] + path = wallet + url = https://code.gri.mw/ardocrat/wallet + branch = grim diff --git a/.hooks/pre-commit b/.hooks/pre-commit new file mode 100755 index 00000000..addfbcab --- /dev/null +++ b/.hooks/pre-commit @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# Copyright 2026 The Grim Developers +# +# 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. + +rustfmt --version &>/dev/null +if [ $? != 0 ]; then + printf "[pre_commit] \033[0;31merror\033[0m: \"rustfmt\" not available. \n" + printf "[pre_commit] \033[0;31merror\033[0m: rustfmt can be installed via - \n" + printf "[pre_commit] $ rustup component add rustfmt-preview \n" + exit 1 +fi + +problem_files=() + +# first collect all the files that need reformatting +for file in $(git diff --name-only --cached); do + if [ ${file: -3} == ".rs" ]; then + rustfmt --check $file &>/dev/null + if [ $? != 0 ]; then + problem_files+=($file) + fi + fi +done + +if [ ${#problem_files[@]} == 0 ]; then + # nothing to do + printf "[pre_commit] rustfmt \033[0;32mok\033[0m \n" +else + # reformat the files that need it and re-stage them. + printf "[pre_commit] the following files were rustfmt'd before commit: \n" + for file in ${problem_files[@]}; do + rustfmt $file + git add $file + printf "\033[0;32m $file\033[0m \n" + done +fi + +exit 0 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..ddd180ed --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,16122 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_atspi_common" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel 2.5.0", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if 1.0.4", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle 2.6.1", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "polyval", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "aes-keywrap" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b6f24a1f796bc46415a1d0d18dc0a8203ccba088acf5def3291c4f61225522" +dependencies = [ + "aes 0.9.1", + "byteorder", +] + +[[package]] +name = "age" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815e87cc8c39227cfff259f9550bd9f1c1a082370eccf4e9a176327fb7f906c9" +dependencies = [ + "age-core", + "base64 0.13.1", + "bech32 0.8.1", + "chacha20poly1305 0.9.1", + "cookie-factory", + "hkdf 0.11.0", + "hmac 0.11.0", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom 7.1.3", + "pin-project", + "rand 0.7.3", + "rand 0.8.6", + "rust-embed", + "scrypt 0.8.1", + "sha2 0.9.9", + "subtle 2.6.1", + "x25519-dalek 1.1.1", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70afa630ef12a4fc666277713efbe6da2bc87bb3f3af0f1149415b701362c615" +dependencies = [ + "base64 0.13.1", + "chacha20poly1305 0.9.1", + "cookie-factory", + "hkdf 0.11.0", + "nom 7.1.3", + "rand 0.8.6", + "secrecy 0.8.0", + "sha2 0.9.9", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.4", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "cesu8", + "jni 0.21.1", + "jni-sys 0.3.0", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot 0.12.5", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools 0.10.5", + "num-traits 0.2.19", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint 0.4.6", + "num-traits 0.2.19", + "paste", + "rayon", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote 1.0.44", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint 0.4.6", + "num-traits 0.2.19", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest 0.10.7", + "num-bigint 0.4.6", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits 0.2.19", + "rand 0.8.6", + "rayon", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "futures-core", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite 0.2.16", + "tokio 1.49.0", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite 0.2.16", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg 1.5.0", + "cfg-if 1.0.4", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if 1.0.4", + "event-listener 5.4.1", + "futures-lite", + "rustix 1.1.3", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if 1.0.4", + "futures-core", + "futures-io", + "rustix 1.1.3", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-socks5" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da2537846e16b96d2972ee52a3b355663872a1a687ce6d57a3b6f6b6a181c89" +dependencies = [ + "thiserror 1.0.69", + "tokio 1.49.0", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite 0.2.16", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio 1.49.0", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures 0.3.31", + "futures-util", + "js-sys", + "tokio 1.49.0", + "tokio-rustls 0.26.4", + "tokio-socks 0.5.3", + "tokio-tungstenite 0.26.2", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_float" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" + +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dde43e75fd43e8a1bf86103336bc699aa8d17ad1be60c76c0bdfd4828e19b78" +dependencies = [ + "autocfg 1.5.0", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec 0.7.6", + "log", + "num-rational 0.4.2", + "num-traits 0.2.19", + "pastey 0.1.1", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec 0.7.6", + "log", + "nom 8.0.0", + "num-rational 0.4.2", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec 0.7.6", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if 1.0.4", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base62" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adf9755786e27479693dedd3271691a92b5e242ab139cacb9fb8e7fb5381111" + +[[package]] +name = "base64" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489d6c0ed21b11d038c31b6ceccca973e65d73ba3bd8ecb9a2babf5546164643" +dependencies = [ + "byteorder", + "safemem", +] + +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dabbe35f96fb9507f7330793dc490461b2962659ac5d427181e451a623751d1" + +[[package]] +name = "bech32" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9ff0bbfd639f15c74af777d81383cf53efb7c93613f6cab67c6c11e05bbf8b" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2 1.0.106", + "quote 1.0.44", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.114", + "which", +] + +[[package]] +name = "binstring" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef" + +[[package]] +name = "bip32" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db40d3dfbeab4e031d78c844642fa0caa0b0db11ce1607ac9d2986dff1405c69" +dependencies = [ + "bs58 0.5.1", + "hmac 0.12.1", + "k256", + "rand_core 0.6.4", + "ripemd", + "secp256k1 0.27.0", + "sha2 0.10.9", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes 0.14.100", + "rand 0.8.6", + "rand_core 0.6.4", + "serde", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitcoin-io" +version = "0.1.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" + +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + +[[package]] +name = "bitcoin_hashes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" +dependencies = [ + "bitcoin-private", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "blake2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94cb07b0da6a73955f8fb85d24c466778e70cda767a568229b104f0264089330" +dependencies = [ + "byte-tools", + "crypto-mac 0.7.0", + "digest 0.8.1", + "opaque-debug 0.2.3", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake2-rfc" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" +dependencies = [ + "arrayvec 0.4.12", + "constant_time_eq 0.1.5", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "constant_time_eq 0.4.2", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "cc", + "cfg-if 1.0.4", + "constant_time_eq 0.4.2", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding 0.1.5", + "byte-tools", + "byteorder", + "generic-array 0.12.4", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.3", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bnum" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e31ea183f6ee62ac8b8a8cf7feddd766317adfb13ff469de57ce033efd6a790" + +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476e9cd489f9e121e02ffa6014a8ef220ecb15c05ed23fc34cca13925dc283fb" + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "sha2 0.10.9", + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +dependencies = [ + "git2", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + +[[package]] +name = "bytecodec" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf4c9d0bbf32eea58d7c0f812058138ee8edaf0f2802b6d03561b504729a325" +dependencies = [ + "byteorder", + "trackable 0.2.24", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "iovec", +] + +[[package]] +name = "bytes" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.10.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +dependencies = [ + "bitflags 2.10.0", + "polling", + "rustix 1.1.3", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.3", + "rustix 1.1.3", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "celes" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55028d5b1eebb35237512a3838ce5583211434a233c8bb179551a7197ffb7bd4" +dependencies = [ + "phf 0.13.1", + "serde", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf3c081b5fba1e5615640aae998e0fbd10c24cbd897ee39ed754a77601a4862" +dependencies = [ + "byteorder", + "keystream", +] + +[[package]] +name = "chacha20" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c80e5460aa66fe3b91d40bcbdab953a597b60053e34d684ac6903f863b680a6" +dependencies = [ + "cfg-if 1.0.4", + "cipher 0.3.0", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if 1.0.4", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18446b09be63d457bbec447509e85f662f32952b035ce892290396bc0b0cff5" +dependencies = [ + "aead 0.4.3", + "chacha20 0.8.2", + "cipher 0.3.0", + "poly1305 0.7.2", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits 0.2.19", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + +[[package]] +name = "cocoa" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c49e86fc36d5704151f5996b7b3795385f50ce09e3be0f47a0cfde869681cf8" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.7.0", + "core-graphics 0.19.2", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "objc", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.2", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes 1.11.1", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[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 = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-str" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3618cccc083bb987a415d85c02ca6c9994ea5b44731ec28b9ecf09658655fba9" + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures 0.3.31", +] + +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-media-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273bf3fc5bf51fd06a7766a84788c1540b6527130a0bce39e00567d6ab9f31f1" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-models" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "657f625ff361906f779745d08375ae3cc9fef87a35fba5f22874cf773010daf4" +dependencies = [ + "hax-lib", + "pastey 0.2.3", + "rand 0.9.2", +] + +[[package]] +name = "core-video-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ecad23610ad9757664d644e369246edde1803fcb43ed72876565098a5d3828" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "libc", + "metal 0.18.0", + "objc", +] + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmos-sdk-proto" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ac39be7373404accccaede7cc1ec942ccef14f0ca18d209967a756bf1dbb1f" +dependencies = [ + "prost", + "tendermint-proto", +] + +[[package]] +name = "cosmrs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e74fa7a22930fe0579bef560f2d64b78415d4c47b9dd976c0635136809471d" +dependencies = [ + "bip32", + "cosmos-sdk-proto", + "ecdsa", + "eyre", + "k256", + "rand_core 0.6.4", + "serde", + "serde_json", + "signature 2.2.0", + "subtle-encoding", + "tendermint", + "tendermint-rpc", + "thiserror 1.0.69", +] + +[[package]] +name = "cosmwasm-core" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5ed3671399bdaa500eeeaacdc9c11ddb93b4a30662c09b845722186748c13" + +[[package]] +name = "cosmwasm-crypto" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bba8d89908fe256c7fe6efaccb54da59689329bc92011a9eb9bad8d615426c29" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "cosmwasm-core", + "curve25519-dalek 4.1.3", + "digest 0.10.7", + "ecdsa", + "ed25519-zebra", + "k256", + "num-traits 0.2.19", + "p256", + "rand_core 0.6.4", + "rayon", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "cosmwasm-derive" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28561fc9ba9ef8ea234c3306fcbf20e6799ea0b78a7ca13386012f371c34a6d0" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "cosmwasm-schema" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6984ab21b47a096e17ae4c73cea2123a704d4b6686c39421247ad67020d76f95" +dependencies = [ + "cosmwasm-schema-derive", + "schemars", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cosmwasm-schema-derive" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01c9214319017f6ebd8e299036e1f717fa9bb6724e758f7d6fb2477599d1a29" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "cosmwasm-std" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf82335c14bd94eeb4d3c461b7aa419ecd7ea13c2efe24b97cd972bdb8044e7d" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "bnum", + "cosmwasm-core", + "cosmwasm-crypto", + "cosmwasm-derive", + "derive_more 1.0.0", + "hex", + "rand_core 0.6.4", + "rmp-serde", + "schemars", + "serde", + "serde-json-wasm", + "sha2 0.10.9", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if 1.0.4", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "croaring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "611eaefca84c93e431ad82dfb848f6e05a99e25148384f45a3852b0fbe1c8086" +dependencies = [ + "byteorder", + "croaring-sys", +] + +[[package]] +name = "croaring-sys" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5fed89265a702f0085844237a7ebbadf8a7c42de6304fddca30a5013f9aecb" +dependencies = [ + "cc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.1", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-mac" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4434400df11d95d556bac068ddfedd482915eb18fe8bea89bc80b6e4b1c179e5" +dependencies = [ + "generic-array 0.12.4", + "subtle 1.0.0", +] + +[[package]] +name = "crypto-mac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" +dependencies = [ + "generic-array 0.14.7", + "subtle 2.6.1", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa 1.0.17", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ct-codecs" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39" + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b85542f99a2dfa2a1b8e192662741c9859a846b296bef1c92ef9b58b5a216" +dependencies = [ + "byteorder", + "digest 0.8.1", + "rand_core 0.5.1", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "serde", + "subtle 2.6.1", + "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 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c359b7249347e46fb28804470d071c921156ad62b3eef5d34e2ba867533dec8" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.6.4", + "subtle-ng", + "zeroize", +] + +[[package]] +name = "cw-controllers" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c1804013d21060b994dea28a080f9eab78a3bcb6b617f05e7634b0600bf7b1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "schemars", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cw-storage-plus" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f13360e9007f51998d42b1bc6b7fa0141f74feae61ed5fd1e5b0a89eec7b5de1" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "cw-utils" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dfee7f12f802431a856984a32bce1cb7da1e6c006b5409e3981035ce562dec" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "schemars", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cw2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04852cd38f044c0751259d5f78255d07590d136b8a86d4e09efdd7666bd6d27" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "semver", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cw20" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42212b6bf29bbdda693743697c621894723f35d3db0d5df930be22903d0e27c" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils", + "schemars", + "serde", +] + +[[package]] +name = "cw3" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5e53c2057526c65d9c88be8b2a564729ebad7a3d87ee97b97665a71446f913a" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils", + "cw20", + "schemars", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cw4" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d33f5c8a6b6cd1bd24e212d7f44967697bfa3c4f9cc3f9a8e1c58f5fe5db032d" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "serde", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2 1.0.106", + "quote 1.0.44", + "strsim 0.11.1", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if 1.0.4", + "hashbrown 0.14.5", + "lock_api 0.4.14", + "once_cell", + "parking_lot_core 0.9.12", + "serde", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "unicode-xid 0.2.6", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "rustc_version", + "syn 2.0.114", + "unicode-xid 0.2.6", +] + +[[package]] +name = "destructure_traitobject" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" + +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array 0.12.4", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle 2.6.1", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13aea89a5c93364a98e9b37b2fa237effbb694d5cfe01c5b70941f7eb087d5e3" +dependencies = [ + "cfg-if 0.1.10", + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if 1.0.4", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi 0.3.9", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi 0.3.9", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "doxygen-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" +dependencies = [ + "phf 0.11.3", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "easy-jsonrpc-mw" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b1a91569d50e3bba3c9febb22ef54d78c6e8a8d8dd91ae859896c8ba05f4e3" +dependencies = [ + "easy-jsonrpc-proc-macro-mw", + "jsonrpc-core", + "rand 0.6.5", + "serde", + "serde_json", +] + +[[package]] +name = "easy-jsonrpc-proc-macro-mw" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6368dbd2c6685fb84fc6e6a4749917ddc98905793fd06341c7e11a2504f2724" +dependencies = [ + "heck 0.3.3", + "proc-macro2 0.4.30", + "quote 0.6.13", + "syn 0.15.44", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect 0.2.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecolor" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature 1.6.4", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "serde", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-compact" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5c0284a5d4b1a2fae017a9fe55fd7d01699711f1b572493f16593e173ea2801" +dependencies = [ + "ct-codecs", + "getrandom 0.4.1", +] + +[[package]] +name = "ed25519-consensus" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8465edc8ee7436ffea81d21a019b16676ee3db267aa8d5a8d729581ecf998b" +dependencies = [ + "curve25519-dalek-ng", + "hex", + "rand_core 0.6.4", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek 3.2.0", + "ed25519 1.5.3", + "rand 0.7.3", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "ed25519-zebra" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d9ce6874da5d4415896cd45ffbc4d1cfc0c4f9c079427bd870742c30f2f65a9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "hashbrown 0.14.5", + "hex", + "rand_core 0.6.4", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "eframe" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot 0.12.5", + "percent-encoding", + "pollster", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.10.0", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-async" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa615b8df996a05c796ff3a37f75945eff1545b552f86b2b0d40b980db5a8216" +dependencies = [ + "atomic_float", + "bitflags 2.10.0", + "egui", + "tokio 1.49.0", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "egui-wgpu" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.18", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed" +dependencies = [ + "ahash", + "egui", + "enum-map", + "image", + "log", + "mime_guess2", + "profiling", + "resvg", +] + +[[package]] +name = "egui_glow" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array 0.14.7", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "serdect 0.2.0", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "emath" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if 1.0.4", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "enum_primitive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4551092f4d519593039259a9ed8daedf0da12e5109c5280338073eaeb81180" +dependencies = [ + "num-traits 0.1.43", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "epaint" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot 0.12.5", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if 1.0.4", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[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 0.2.16", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle 2.6.1", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flex-error" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c606d892c9de11507fa0dcffc116434f94e105d0bbdc4e405b61519464c49d7b" +dependencies = [ + "eyre", + "paste", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[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 = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "fuchsia-zircon" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" +dependencies = [ + "bitflags 1.3.2", + "fuchsia-zircon-sys", +] + +[[package]] +name = "fuchsia-zircon-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" + +[[package]] +name = "futures" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" + +[[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", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api 0.4.14", + "parking_lot 0.12.5", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[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 0.2.16", + "pin-utils", + "slab", +] + +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "serde", + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if 1.0.4", + "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 1.0.4", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug 0.3.1", + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.10.0", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[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", + "http 1.4.0", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[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 = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.10.0", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.10.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "grim" +version = "0.3.6" +dependencies = [ + "android-activity", + "android_logger", + "anyhow", + "arboard", + "async-std", + "async-wsocket", + "backtrace", + "base64 0.22.1", + "built", + "bytes 1.11.1", + "chrono", + "dirs 6.0.0", + "eframe", + "egui", + "egui-async", + "egui_extras", + "env_logger", + "futures 0.3.31", + "gif", + "grin_api", + "grin_chain", + "grin_config", + "grin_core", + "grin_keychain", + "grin_p2p", + "grin_servers", + "grin_util", + "grin_wallet_api", + "grin_wallet_controller", + "grin_wallet_impls", + "grin_wallet_libwallet", + "grin_wallet_util", + "hex", + "http-body-util", + "hyper 1.8.1", + "hyper-proxy2", + "hyper-socks2", + "hyper-tls 0.6.0", + "hyper-util", + "image", + "interprocess", + "jni 0.21.1", + "lazy_static", + "local-ip-address", + "log", + "log4rs", + "nokhwa", + "nostr-relay-pool", + "nostr-sdk", + "num-bigint 0.4.6", + "nym-sdk", + "openssl", + "parking_lot 0.12.5", + "pin-project", + "qrcode", + "qrcodegen", + "rand 0.9.2", + "regex", + "reqwest 0.12.28", + "rfd", + "ring 0.16.20", + "rkv", + "rqrr", + "rust-i18n", + "rustls 0.23.40", + "serde", + "serde_derive", + "serde_json", + "serde_yaml", + "sha2 0.10.9", + "sys-locale", + "thiserror 2.0.18", + "tokio 0.2.25", + "tokio 1.49.0", + "tokio-socks 0.5.3", + "tokio-tungstenite 0.26.2", + "tokio-util 0.2.0", + "toml 0.9.11+spec-1.1.0", + "ur", + "url", + "usvg", + "uuid 0.8.2", + "wgpu", + "winit", + "winresource", +] + +[[package]] +name = "grin_api" +version = "5.4.1" +dependencies = [ + "async-stream", + "bytes 1.11.1", + "easy-jsonrpc-mw", + "futures 0.3.31", + "grin_chain", + "grin_core", + "grin_p2p", + "grin_pool", + "grin_store", + "grin_util", + "http 0.2.12", + "hyper 0.14.32", + "hyper-rustls 0.23.2", + "hyper-timeout", + "lazy_static", + "log", + "regex", + "ring 0.16.20", + "rustls 0.20.9", + "rustls-pemfile", + "serde", + "serde_derive", + "serde_json", + "thiserror 1.0.69", + "tokio 1.49.0", + "tokio-rustls 0.23.4", + "url", +] + +[[package]] +name = "grin_chain" +version = "5.4.1" +dependencies = [ + "bit-vec 0.6.3", + "bitflags 1.3.2", + "byteorder", + "chrono", + "croaring", + "enum_primitive", + "grin_core", + "grin_keychain", + "grin_store", + "grin_util", + "lazy_static", + "log", + "lru-cache", + "serde", + "serde_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "grin_config" +version = "5.4.1" +dependencies = [ + "dirs 2.0.2", + "grin_core", + "grin_p2p", + "grin_servers", + "grin_util", + "rand 0.6.5", + "serde", + "serde_derive", + "toml 0.5.11", +] + +[[package]] +name = "grin_core" +version = "5.4.1" +dependencies = [ + "blake2-rfc", + "byteorder", + "bytes 0.5.6", + "chrono", + "croaring", + "enum_primitive", + "grin_keychain", + "grin_util", + "lazy_static", + "log", + "lru-cache", + "num", + "num-bigint 0.2.6", + "rand 0.6.5", + "serde", + "serde_derive", + "siphasher 0.3.11", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "grin_keychain" +version = "5.4.1" +dependencies = [ + "blake2-rfc", + "byteorder", + "digest 0.9.0", + "grin_util", + "hmac 0.11.0", + "lazy_static", + "log", + "pbkdf2 0.8.0", + "rand 0.6.5", + "ripemd160", + "serde", + "serde_derive", + "serde_json", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "grin_p2p" +version = "5.4.1" +dependencies = [ + "bitflags 1.3.2", + "built", + "bytes 0.5.6", + "chrono", + "enum_primitive", + "grin_chain", + "grin_core", + "grin_store", + "grin_util", + "log", + "lru-cache", + "num", + "rand 0.6.5", + "serde", + "serde_derive", + "tempfile", +] + +[[package]] +name = "grin_pool" +version = "5.4.1" +dependencies = [ + "blake2-rfc", + "chrono", + "grin_core", + "grin_keychain", + "grin_util", + "log", + "rand 0.6.5", + "serde", + "serde_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "grin_secp256k1zkp" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7bb95f155b1eede2648a1b9afbba82bc3d9f2af0518b478767559a572bd973" +dependencies = [ + "arrayvec 0.7.6", + "cc", + "libc", + "rand 0.5.6", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "grin_servers" +version = "5.4.1" +dependencies = [ + "async-stream", + "chrono", + "fs2", + "futures 0.3.31", + "grin_api", + "grin_chain", + "grin_core", + "grin_keychain", + "grin_p2p", + "grin_pool", + "grin_store", + "grin_util", + "http 0.2.12", + "hyper 0.14.32", + "hyper-rustls 0.23.2", + "log", + "rand 0.6.5", + "rustls 0.20.9", + "serde", + "serde_derive", + "serde_json", + "tokio 1.49.0", + "tokio-util 0.7.18", + "walkdir", +] + +[[package]] +name = "grin_store" +version = "5.4.1" +dependencies = [ + "byteorder", + "croaring", + "grin_core", + "grin_util", + "heed", + "libc", + "log", + "memmap", + "serde", + "serde_derive", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "grin_util" +version = "5.4.1" +dependencies = [ + "anyhow", + "backtrace", + "base64 0.12.3", + "byteorder", + "grin_secp256k1zkp", + "lazy_static", + "log", + "log4rs", + "parking_lot 0.10.2", + "rand 0.6.5", + "serde", + "serde_derive", + "walkdir", + "zeroize", + "zip", +] + +[[package]] +name = "grin_wallet_api" +version = "5.4.0-alpha.1" +dependencies = [ + "base64 0.12.3", + "chrono", + "easy-jsonrpc-mw", + "ed25519-dalek 1.0.1", + "grin_core", + "grin_keychain", + "grin_util", + "grin_wallet_config", + "grin_wallet_impls", + "grin_wallet_libwallet", + "grin_wallet_util", + "log", + "rand 0.6.5", + "ring 0.16.20", + "serde", + "serde_derive", + "serde_json", + "uuid 0.8.2", +] + +[[package]] +name = "grin_wallet_config" +version = "5.4.0-alpha.1" +dependencies = [ + "dirs 2.0.2", + "grin_core", + "grin_util", + "grin_wallet_util", + "rand 0.6.5", + "serde", + "serde_derive", + "toml 0.5.11", +] + +[[package]] +name = "grin_wallet_controller" +version = "5.4.0-alpha.1" +dependencies = [ + "chrono", + "easy-jsonrpc-mw", + "futures 0.3.31", + "grin_api", + "grin_chain", + "grin_core", + "grin_keychain", + "grin_util", + "grin_wallet_api", + "grin_wallet_config", + "grin_wallet_impls", + "grin_wallet_libwallet", + "grin_wallet_util", + "hyper 0.14.32", + "lazy_static", + "log", + "prettytable-rs", + "qr_code", + "rand 0.7.3", + "ring 0.16.20", + "serde", + "serde_derive", + "serde_json", + "term 0.6.1", + "thiserror 1.0.69", + "tokio 0.2.25", + "url", + "uuid 0.8.2", +] + +[[package]] +name = "grin_wallet_impls" +version = "5.4.0-alpha.1" +dependencies = [ + "base64 0.12.3", + "blake2-rfc", + "byteorder", + "chrono", + "data-encoding", + "ed25519-dalek 1.0.1", + "futures 0.3.31", + "grin_api", + "grin_chain", + "grin_core", + "grin_keychain", + "grin_store", + "grin_util", + "grin_wallet_config", + "grin_wallet_libwallet", + "grin_wallet_util", + "lazy_static", + "log", + "rand 0.6.5", + "regex", + "reqwest 0.10.10", + "ring 0.16.20", + "serde", + "serde_derive", + "serde_json", + "sysinfo 0.29.11", + "thiserror 1.0.69", + "timer", + "tokio 0.2.25", + "url", + "uuid 0.8.2", + "x25519-dalek 0.6.0", +] + +[[package]] +name = "grin_wallet_libwallet" +version = "5.4.0-alpha.1" +dependencies = [ + "age", + "base64 0.9.3", + "bech32 0.7.3", + "blake2-rfc", + "bs58 0.3.1", + "byteorder", + "chacha20 0.8.2", + "chrono", + "curve25519-dalek 2.1.3", + "ed25519-dalek 1.0.1", + "grin_core", + "grin_keychain", + "grin_store", + "grin_util", + "grin_wallet_config", + "grin_wallet_util", + "hmac 0.12.1", + "lazy_static", + "log", + "num-bigint 0.2.6", + "rand 0.6.5", + "regex", + "secrecy 0.6.0", + "serde", + "serde_derive", + "serde_json", + "sha2 0.10.9", + "strum 0.18.0", + "strum_macros 0.18.0", + "thiserror 1.0.69", + "uuid 0.8.2", + "x25519-dalek 0.6.0", +] + +[[package]] +name = "grin_wallet_util" +version = "5.4.0-alpha.1" +dependencies = [ + "data-encoding", + "ed25519-dalek 1.0.1", + "grin_util", + "rand 0.6.5", + "serde", + "serde_derive", + "sha3 0.8.2", + "thiserror 1.0.69", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle 2.6.1", +] + +[[package]] +name = "h2" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4728fd124914ad25e99e3d15a9361a879f6620f63cb56bbb08f95abb97a535" +dependencies = [ + "bytes 0.5.6", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 1.9.3", + "slab", + "tokio 0.2.25", + "tokio-util 0.3.1", + "tracing", + "tracing-futures", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes 1.11.1", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.0", + "slab", + "tokio 1.49.0", + "tokio-util 0.7.18", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes 1.11.1", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap 2.13.0", + "slab", + "tokio 1.49.0", + "tokio-util 0.7.18", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if 1.0.4", + "crunchy", + "num-traits 0.2.19", + "zerocopy", +] + +[[package]] +name = "handlebars" +version = "3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4498fc115fa7d34de968184e473529abb40eeb6be8bc5f7faba3d08c316cb3e3" +dependencies = [ + "log", + "pest", + "pest_derive", + "quick-error", + "serde", + "serde_json", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "hax-lib" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc" +dependencies = [ + "hax-lib-macros", + "num-bigint 0.4.6", + "num-traits 0.2.19", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8755751e760b11021765bb04cb4a6c4e24742688d9f3aa14c2079638f537b0f" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f177c9ae8ea456e2f71ff3c1ea47bf4464f772a05133fcbba56cd5ba169035a2" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "serde", + "serde_json", + "uuid 1.20.0", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "headers-core", + "http 1.4.0", + "httpdate 1.0.3", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "heed" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" +dependencies = [ + "bitflags 2.10.0", + "byteorder", + "heed-traits", + "heed-types", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "serde", + "synchronoise", + "url", +] + +[[package]] +name = "heed-traits" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" + +[[package]] +name = "heed-types" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d" +dependencies = [ + "bincode", + "byteorder", + "heed-traits", + "serde", + "serde_json", +] + +[[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 = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec 0.7.6", +] + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes 1.11.1", + "cfg-if 1.0.4", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2 0.4.13", + "hickory-proto", + "http 1.4.0", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "rustls 0.23.40", + "thiserror 2.0.18", + "tinyvec", + "tokio 1.49.0", + "tokio-rustls 0.26.4", + "tracing", + "url", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring 0.17.14", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if 1.0.4", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot 0.12.5", + "rand 0.10.1", + "resolv-conf", + "rustls 0.23.40", + "smallvec", + "system-configuration 0.7.0", + "thiserror 2.0.18", + "tokio 1.49.0", + "tokio-rustls 0.26.4", + "tracing", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01706d578d5c281058480e673ae4086a9f4710d8df1ad80a5b03e39ece5f886b" +dependencies = [ + "digest 0.9.0", + "hmac 0.11.0", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +dependencies = [ + "crypto-mac 0.11.0", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-sha1-compact" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" + +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-sha512" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes 1.11.1", + "fnv", + "itoa 1.0.17", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes 1.11.1", + "itoa 1.0.17", +] + +[[package]] +name = "http-body" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d5ff830006f7646652e057693569bfe0d51760c0085a071769d142a205111b" +dependencies = [ + "bytes 0.5.6", + "http 0.2.12", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes 1.11.1", + "http 0.2.12", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes 1.11.1", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes 1.11.1", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpcodec" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f49d64351430cabd543943b79d48aaf0bc95a41d9ccf5b8774c2cfd23422775" +dependencies = [ + "bytecodec", + "trackable 0.2.24", +] + +[[package]] +name = "httpdate" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "ctutils", + "typenum", +] + +[[package]] +name = "hyper" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a6f157065790a3ed2f88679250419b5cdd96e714a0d65f7797fd337186e96bb" +dependencies = [ + "bytes 0.5.6", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.2.7", + "http 0.2.12", + "http-body 0.3.1", + "httparse", + "httpdate 0.3.2", + "itoa 0.4.8", + "pin-project", + "socket2 0.3.19", + "tokio 0.2.25", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes 1.11.1", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate 1.0.3", + "itoa 1.0.17", + "pin-project-lite 0.2.16", + "socket2 0.5.10", + "tokio 1.49.0", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes 1.11.1", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate 1.0.3", + "itoa 1.0.17", + "pin-project-lite 0.2.16", + "pin-utils", + "smallvec", + "tokio 1.49.0", + "want", +] + +[[package]] +name = "hyper-proxy2" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9043b7b23fb0bc4a1c7014c27b50a4fc42cc76206f71d34fc0dfe5b28ddc3faf" +dependencies = [ + "bytes 1.11.1", + "futures-util", + "headers", + "http 1.4.0", + "hyper 1.8.1", + "hyper-tls 0.6.0", + "hyper-util", + "native-tls", + "pin-project-lite 0.2.16", + "tokio 1.49.0", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37743cc83e8ee85eacfce90f2f4102030d9ff0a95244098d781e9bee4a90abb6" +dependencies = [ + "bytes 0.5.6", + "futures-util", + "hyper 0.13.10", + "log", + "rustls 0.18.1", + "tokio 0.2.25", + "tokio-rustls 0.14.1", + "webpki 0.21.4", +] + +[[package]] +name = "hyper-rustls" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" +dependencies = [ + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.20.9", + "rustls-native-certs 0.6.3", + "tokio 1.49.0", + "tokio-rustls 0.23.4", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio 1.49.0", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.40", + "tokio 1.49.0", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-socks2" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c227614c208f7e7c2e040526912604a1a957fe467c9c2f5b06c5d032337dab" +dependencies = [ + "async-socks5", + "http 1.4.0", + "hyper 1.8.1", + "hyper-tls 0.6.0", + "hyper-util", + "thiserror 1.0.69", + "tokio 1.49.0", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.32", + "pin-project-lite 0.2.16", + "tokio 1.49.0", + "tokio-io-timeout", +] + +[[package]] +name = "hyper-tls" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d979acc56dcb5b8dddba3917601745e877576475aa046df3226eabdecef78eed" +dependencies = [ + "bytes 0.5.6", + "hyper 0.13.10", + "native-tls", + "tokio 0.2.25", + "tokio-tls", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes 1.11.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "native-tls", + "tokio 1.49.0", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite 0.2.16", + "socket2 0.6.2", + "tokio 1.49.0", + "tower-service", + "tracing", +] + +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.13.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a86226a7a16632de6723449ee5fe70bac5af718bc642ee9ca2f0f6e14fa1fa" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "lazy_static", + "log", + "parking_lot 0.12.5", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26a3d3569737dfaac7fc1c4078e6af07471c3060b8e570bcd83cdd5f4685395" +dependencies = [ + "dashmap", + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "lazy_static", + "proc-macro-error", + "proc-macro2 1.0.106", + "quote 1.0.44", + "strsim 0.10.0", + "syn 2.0.114", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys 0.8.7", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits 0.2.19", + "png 0.18.0", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg 1.5.0", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding 0.3.3", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "interprocess" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53bf2b0e0785c5394a7392f66d7c4fb9c653633c29b27a932280da3cb344c66a" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio 1.49.0", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ + "libc", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.2", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[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 = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if 1.0.4", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if 1.0.4", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpc-core" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc15eef5f8b6bef5ac5f7440a957ff95d036e2f98706947741bfc93d1976db4c" +dependencies = [ + "futures 0.1.31", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "jwt-simple" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f45048cd18221c81d27dd4621d51df25b33f11b68655d79f67a9f9d3eb48fecc" +dependencies = [ + "anyhow", + "binstring", + "blake2b_simd", + "coarsetime", + "ct-codecs", + "ed25519-compact", + "hmac-sha1-compact", + "hmac-sha256", + "hmac-sha512", + "k256", + "p256", + "p384", + "rand 0.8.6", + "serde", + "serde_json", + "superboring", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if 1.0.4", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + +[[package]] +name = "keystream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33070833c9ee02266356de0c43f723152bd38bd96ddf52c82b3af10c9138b28" + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec 0.7.6", + "euclid", + "smallvec", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "libcrux-aesgcm" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99f2a019dab4097585a7d4f5b9deebe46cd1e628b16a5bc4cb0ce35e1da334e6" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc08d044676af21343b32b988411fa98dbb5cf65a03c9df478ced221bbdfdb1b" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1e5fd8476a6ed609d24ef42aee5ab6f99f7c65d054f92412da9f499e423299" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65f73ce79337c762eb38bbac91e4c9b9e60cf318e8501b812750c640814d45e" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-ed25519" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835919315b7042fe9e03b6458efe0db94bf2aa7b873934dbee5b5463a8124b43" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "rand_core 0.9.5", + "tls_codec", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2637dc87d158e1f1b550fd9b226443e84153fded4de69028d897b534d16d22e6" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1a89ca0c89be3a268a921e47105fb7873badf7267f5e3ebf4ea46baedd73ef" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7a242707d65960770bd7e14e4f18a92bdf0b967777dd404887db8d087a643b" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b5db005ff8001e026b73a6842ee81bbef8ec5ff0e1915a67ae65fd2a9fafa5" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12631592f491d22fd1a176d32b2c6edfb673998fd3987e9d95f8fa79ad2a737b" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" +dependencies = [ + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "libcrux-ml-dsa" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a72929ed421cc3bf16a946b3e7d2a58d215b0b5c2a12be26b53629f081bf49b2" +dependencies = [ + "core-models", + "hax-lib", + "libcrux-intrinsics", + "libcrux-macros", + "libcrux-platform", + "libcrux-sha3", + "tls_codec", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14ab3e477de9df6ee1273a114018ff62c4996ca9220070c4e5cb1743f94a67d" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4778ba25cb08bb8a96bd100e19ed9aecf78337198fd176036e21042b2dd99bc" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02491808ee5b9db8cb65fad64ae0be812db64beef179d945c00c7787dc7dfcf9" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-psq" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ade7aa5e1b4b400c716b313cbf69070988dd005f92e961c2da4c3c42fbea4" +dependencies = [ + "libcrux-aesgcm", + "libcrux-chacha20poly1305", + "libcrux-ecdh", + "libcrux-ed25519", + "libcrux-hkdf", + "libcrux-hmac", + "libcrux-kem", + "libcrux-ml-dsa", + "libcrux-ml-kem", + "libcrux-sha2", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce650f3041b44ba40d4263852347d007cd2cd9d1cc856a6f6c8b2e10c3fd40b" +dependencies = [ + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d253473f259fc74a280c43f29c464f7e374abdf28b4942234dc707f529d4b7" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ae0b7d0e1cc4793a609fd0ff2ca3b3a3fabae523770c619a3d4bc86417b0d7" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e4fa89f3f5e34b47f928b22b1b78395a0d4ec23b1f583db635f128159d65f" +dependencies = [ + "libcrux-secrets", + "rand 0.9.2", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if 1.0.4", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lioness" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae926706ba42c425c9457121178330d75e273df2e82e28b758faf3de3a9acb9" +dependencies = [ + "arrayref", + "blake2 0.8.1", + "chacha", + "keystream", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lmdb-master-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" +dependencies = [ + "cc", + "doxygen-rs", + "libc", +] + +[[package]] +name = "local-ip-address" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + +[[package]] +name = "lock_api" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4da24a77a3d8a6d4862d95f72e6fdb9c09a643ecdb402d754004a557f2bec75" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "log-mdc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" + +[[package]] +name = "log4rs" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e947bb896e702c711fccc2bf02ab2abb6072910693818d1d6b07ee2b9dfd86c" +dependencies = [ + "anyhow", + "arc-swap", + "chrono", + "derive_more 2.1.1", + "flate2", + "fnv", + "humantime", + "libc", + "log", + "log-mdc", + "mock_instant", + "parking_lot 0.12.5", + "rand 0.9.2", + "serde", + "serde-value", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "thread-id", + "typemap-ors", + "unicode-segmentation", + "winapi 0.3.9", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if 1.0.4", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if 1.0.4", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6585fd95e7bb50d6cc31e20d4cf9afb4e2ba16c5846fc76793f11218da9c475b" +dependencies = [ + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg 1.5.0", +] + +[[package]] +name = "metal" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e198a0ee42bdbe9ef2c09d0b9426f3b2b47d90d93a4a9b0395c4cea605e92dc0" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa", + "core-graphics 0.19.2", + "foreign-types 0.3.2", + "log", + "objc", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf 0.11.3", + "phf_shared 0.11.3", + "unicase", +] + +[[package]] +name = "minicbor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7005aaf257a59ff4de471a9d5538ec868a21586534fff7f85dd97d4043a6139" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1154809406efdb7982841adb6311b3d095b46f78342dd646736122fe6b19e267" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 1.0.109", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" +dependencies = [ + "cfg-if 0.1.10", + "fuchsia-zircon", + "fuchsia-zircon-sys", + "iovec", + "kernel32-sys", + "libc", + "log", + "miow 0.2.2", + "net2", + "slab", + "winapi 0.2.8", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "mio-named-pipes" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0840c1c50fd55e521b247f949c241c9997709f23bd7f023b9762cd561e935656" +dependencies = [ + "log", + "mio 0.6.23", + "miow 0.3.7", + "winapi 0.3.9", +] + +[[package]] +name = "mio-uds" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afcb699eb26d4332647cc848492bbc15eafb26f08d0304550d5aa1f612e066f0" +dependencies = [ + "iovec", + "libc", + "mio 0.6.23", +] + +[[package]] +name = "miow" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" +dependencies = [ + "kernel32-sys", + "net2", + "winapi 0.2.8", + "ws2_32-sys", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "ml-dsa" +version = "0.1.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163f15320f3fba11760c373af52d7f69d638482c2c350d877fb06513b1c3137c" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "sha3 0.11.0", + "signature 3.0.0", +] + +[[package]] +name = "mock_instant" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits 0.2.19", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid 1.20.0", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits 0.2.19", + "pxfm", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec 0.7.6", + "bit-set", + "bitflags 2.10.0", + "cfg-if 1.0.4", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap 2.13.0", + "libm", + "log", + "num-traits 0.2.19", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys 0.3.0", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.0", +] + +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.10.0", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot 0.12.5", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2 1.0.106", + "quote 1.0.44", + "serde", + "syn 2.0.114", +] + +[[package]] +name = "net2" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13b648036a2339d06de780866fbdfda0dde886de7b3af2ddeba8b14f4ee34ac" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nokhwa" +version = "0.10.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cae50786bfa1214ed441f98addbea51ca1b9aaa9e4bf5369cda36654b3efaa" +dependencies = [ + "flume", + "image", + "nokhwa-bindings-linux", + "nokhwa-bindings-macos", + "nokhwa-bindings-windows", + "nokhwa-core", + "parking_lot 0.12.5", + "paste", + "thiserror 2.0.18", +] + +[[package]] +name = "nokhwa-bindings-linux" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd666aaa41d14357817bd9a981773a73c4d00b34d344cfc244e47ebd397b1ec" +dependencies = [ + "nokhwa-core", + "v4l", +] + +[[package]] +name = "nokhwa-bindings-macos" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de78eb4a2d47a68f490899aa0516070d7a972f853ec2bb374ab53be0bd39b60f" +dependencies = [ + "block", + "cocoa-foundation", + "core-foundation 0.10.1", + "core-media-sys", + "core-video-sys", + "flume", + "nokhwa-core", + "objc", + "once_cell", +] + +[[package]] +name = "nokhwa-bindings-windows" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899799275c93ef69bbe8cb888cf6f8249abe751cbc50be5299105022aec14a1c" +dependencies = [ + "nokhwa-core", + "once_cell", + "windows 0.62.2", +] + +[[package]] +name = "nokhwa-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109975552bbd690894f613bce3d408222911e317197c72b2e8b9a1912dc261ae" +dependencies = [ + "bytes 1.11.1", + "image", + "thiserror 2.0.18", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nostr" +version = "0.44.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "bip39", + "bitcoin_hashes 0.14.100", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305 0.10.1", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt 0.11.0", + "secp256k1 0.29.1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio 1.49.0", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio 1.49.0", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio 1.49.0", + "tracing", +] + +[[package]] +name = "ntapi" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" +dependencies = [ + "num-bigint 0.2.6", + "num-complex", + "num-integer", + "num-iter", + "num-rational 0.2.4", + "num-traits 0.2.19", +] + +[[package]] +name = "num-bigint" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304" +dependencies = [ + "autocfg 1.5.0", + "num-integer", + "num-traits 0.2.19", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits 0.2.19", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits 0.2.19", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg 1.5.0", + "num-traits 0.2.19", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg 1.5.0", + "num-integer", + "num-traits 0.2.19", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg 1.5.0", + "num-bigint 0.2.6", + "num-integer", + "num-traits 0.2.19", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.6", + "num-integer", + "num-traits 0.2.19", +] + +[[package]] +name = "num-traits" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg 1.5.0", + "libm", +] + +[[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 = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "nym-api-requests" +version = "1.21.1" +dependencies = [ + "bs58 0.5.1", + "celes", + "cosmrs", + "cosmwasm-std", + "ecdsa", + "hex", + "humantime-serde", + "nym-coconut-dkg-common", + "nym-compact-ecash", + "nym-config", + "nym-contracts-common", + "nym-credentials-interface", + "nym-crypto", + "nym-ecash-signer-check-types", + "nym-ecash-time", + "nym-kkt-ciphersuite", + "nym-mixnet-contract-common", + "nym-network-defaults", + "nym-node-requests", + "nym-noise-keys", + "nym-serde-helpers", + "nym-ticketbooks-merkle", + "schemars", + "serde", + "serde_json", + "sha2 0.10.9", + "strum 0.28.0", + "strum_macros 0.28.0", + "tendermint", + "tendermint-rpc", + "thiserror 2.0.18", + "time", + "tracing", + "utoipa", +] + +[[package]] +name = "nym-bandwidth-controller" +version = "1.21.1" +dependencies = [ + "async-trait", + "log", + "nym-credential-storage", + "nym-credentials", + "nym-credentials-interface", + "nym-crypto", + "nym-ecash-time", + "nym-task", + "nym-validator-client", + "rand 0.8.6", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-bin-common" +version = "1.21.1" +dependencies = [ + "const-str", + "log", + "schemars", + "serde", + "tracing", + "tracing-subscriber", + "utoipa", + "vergen", +] + +[[package]] +name = "nym-bls12_381-fork" +version = "0.8.0-forked" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce84633751030f960a2fd167b5270ec21da4c40d9b6400e1b56676a682fe6f3d" +dependencies = [ + "digest 0.10.7", + "ff", + "group", + "pairing", + "rand_core 0.6.4", + "serde", + "serdect 0.3.0", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "nym-client-core" +version = "1.21.1" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bs58 0.5.1", + "cfg-if 1.0.4", + "futures 0.3.31", + "getrandom 0.3.3", + "gloo-timers", + "http-body-util", + "humantime", + "hyper 1.8.1", + "hyper-util", + "nym-bandwidth-controller", + "nym-client-core-config-types", + "nym-client-core-gateways-storage", + "nym-client-core-surb-storage", + "nym-credential-storage", + "nym-credentials-interface", + "nym-crypto", + "nym-ecash-time", + "nym-gateway-client", + "nym-gateway-requests", + "nym-http-api-client", + "nym-id", + "nym-mixnet-client", + "nym-mixnet-contract-common", + "nym-network-defaults", + "nym-nonexhaustive-delayqueue", + "nym-pemstore", + "nym-sphinx", + "nym-statistics-common", + "nym-task", + "nym-topology", + "nym-validator-client", + "nym-wasm-utils", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "serde_json", + "sha2 0.10.9", + "si-scale", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "tokio-stream", + "tokio-tungstenite 0.20.1", + "tokio_with_wasm", + "tracing", + "tungstenite 0.20.1", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasmtimer", + "zeroize", +] + +[[package]] +name = "nym-client-core-config-types" +version = "1.21.1" +dependencies = [ + "humantime-serde", + "nym-config", + "nym-pemstore", + "nym-sphinx-addressing", + "nym-sphinx-params", + "nym-statistics-common", + "serde", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "nym-client-core-gateways-storage" +version = "1.21.1" +dependencies = [ + "anyhow", + "async-trait", + "nym-crypto", + "nym-gateway-client", + "nym-gateway-requests", + "serde", + "sqlx", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "nym-client-core-surb-storage" +version = "1.21.1" +dependencies = [ + "anyhow", + "async-trait", + "dashmap", + "nym-crypto", + "nym-sphinx", + "nym-sqlx-pool-guard", + "nym-task", + "sqlx", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "tracing", +] + +[[package]] +name = "nym-coconut-dkg-common" +version = "1.21.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils", + "cw2", + "cw4", + "nym-contracts-common", + "nym-multisig-contract-common", +] + +[[package]] +name = "nym-common" +version = "1.21.1" +dependencies = [ + "tracing", + "tracing-test", +] + +[[package]] +name = "nym-compact-ecash" +version = "1.21.1" +dependencies = [ + "bincode", + "bs58 0.5.1", + "cfg-if 1.0.4", + "digest 0.10.7", + "ff", + "group", + "itertools 0.14.0", + "nym-bls12_381-fork", + "nym-network-defaults", + "nym-pemstore", + "rand 0.8.6", + "serde", + "sha2 0.10.9", + "subtle 2.6.1", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "nym-config" +version = "1.21.1" +dependencies = [ + "dirs 6.0.0", + "handlebars", + "log", + "nym-network-defaults", + "serde", + "thiserror 2.0.18", + "toml 0.8.23", + "url", +] + +[[package]] +name = "nym-contracts-common" +version = "1.21.1" +dependencies = [ + "bs58 0.5.1", + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "serde", + "thiserror 2.0.18", + "vergen", +] + +[[package]] +name = "nym-credential-storage" +version = "1.21.1" +dependencies = [ + "anyhow", + "async-trait", + "bincode", + "log", + "nym-compact-ecash", + "nym-credentials", + "nym-ecash-time", + "nym-sqlx-pool-guard", + "serde", + "sqlx", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "zeroize", +] + +[[package]] +name = "nym-credential-utils" +version = "1.21.1" +dependencies = [ + "log", + "nym-bandwidth-controller", + "nym-client-core", + "nym-config", + "nym-credential-storage", + "nym-credentials", + "nym-credentials-interface", + "nym-ecash-time", + "nym-validator-client", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", +] + +[[package]] +name = "nym-credentials" +version = "1.21.1" +dependencies = [ + "bincode", + "cosmrs", + "log", + "nym-api-requests", + "nym-bls12_381-fork", + "nym-credentials-interface", + "nym-crypto", + "nym-ecash-contract-common", + "nym-ecash-time", + "nym-http-api-client", + "nym-network-defaults", + "nym-serde-helpers", + "nym-validator-client", + "serde", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "wasmtimer", + "zeroize", +] + +[[package]] +name = "nym-credentials-interface" +version = "1.21.1" +dependencies = [ + "nym-bls12_381-fork", + "nym-compact-ecash", + "nym-ecash-time", + "nym-network-defaults", + "nym-upgrade-mode-check", + "rand 0.8.6", + "serde", + "strum 0.28.0", + "strum_macros 0.28.0", + "thiserror 2.0.18", + "time", + "utoipa", +] + +[[package]] +name = "nym-crypto" +version = "1.21.1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "aes-gcm-siv", + "base64 0.22.1", + "blake3", + "bs58 0.5.1", + "cipher 0.4.4", + "ctr", + "curve25519-dalek 4.1.3", + "digest 0.10.7", + "ed25519-dalek 2.2.0", + "generic-array 0.14.7", + "hkdf 0.12.4", + "hmac 0.12.1", + "jwt-simple", + "libcrux-curve25519", + "libcrux-psq", + "nym-pemstore", + "nym-sphinx-types", + "rand 0.8.6", + "rand 0.9.2", + "serde", + "serde_bytes", + "sha2 0.10.9", + "subtle-encoding", + "thiserror 2.0.18", + "x25519-dalek 2.0.1", + "zeroize", +] + +[[package]] +name = "nym-ecash-contract-common" +version = "1.21.1" +dependencies = [ + "bs58 0.5.1", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-utils", + "nym-multisig-contract-common", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-ecash-signer-check-types" +version = "1.21.1" +dependencies = [ + "nym-coconut-dkg-common", + "nym-crypto", + "semver", + "serde", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "utoipa", +] + +[[package]] +name = "nym-ecash-time" +version = "1.21.1" +dependencies = [ + "nym-compact-ecash", + "time", +] + +[[package]] +name = "nym-exit-policy" +version = "1.21.1" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "utoipa", +] + +[[package]] +name = "nym-gateway-client" +version = "1.21.1" +dependencies = [ + "futures 0.3.31", + "getrandom 0.2.17", + "gloo-utils", + "nym-bandwidth-controller", + "nym-credential-storage", + "nym-credentials", + "nym-credentials-interface", + "nym-crypto", + "nym-gateway-requests", + "nym-http-api-client", + "nym-network-defaults", + "nym-pemstore", + "nym-sphinx", + "nym-statistics-common", + "nym-task", + "nym-validator-client", + "nym-wasm-utils", + "rand 0.8.6", + "serde", + "si-scale", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "tokio-stream", + "tokio-tungstenite 0.20.1", + "tracing", + "tungstenite 0.20.1", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasmtimer", + "zeroize", +] + +[[package]] +name = "nym-gateway-requests" +version = "1.21.1" +dependencies = [ + "bs58 0.5.1", + "futures 0.3.31", + "generic-array 0.14.7", + "nym-credentials", + "nym-credentials-interface", + "nym-crypto", + "nym-pemstore", + "nym-serde-helpers", + "nym-sphinx", + "nym-statistics-common", + "nym-task", + "rand 0.8.6", + "serde", + "serde_json", + "strum 0.28.0", + "subtle 2.6.1", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "tracing", + "tungstenite 0.20.1", + "wasmtimer", + "zeroize", +] + +[[package]] +name = "nym-group-contract-common" +version = "1.21.1" +dependencies = [ + "cosmwasm-schema", + "cw-controllers", + "cw4", + "schemars", + "serde", +] + +[[package]] +name = "nym-http-api-client" +version = "1.21.1" +dependencies = [ + "async-trait", + "bincode", + "bytes 1.11.1", + "cfg-if 1.0.4", + "encoding_rs", + "fastrand", + "hickory-resolver", + "http 1.4.0", + "inventory", + "itertools 0.14.0", + "mime", + "nym-bin-common", + "nym-http-api-client-macro", + "nym-http-api-common", + "nym-network-defaults", + "once_cell", + "reqwest 0.13.4", + "rustls 0.23.40", + "serde", + "serde_json", + "serde_plain", + "serde_yaml", + "thiserror 2.0.18", + "tokio 1.49.0", + "tracing", + "url", + "wasmtimer", + "webpki-roots 0.26.11", +] + +[[package]] +name = "nym-http-api-client-macro" +version = "1.21.1" +dependencies = [ + "proc-macro-crate", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "uuid 1.20.0", +] + +[[package]] +name = "nym-http-api-common" +version = "1.21.1" +dependencies = [ + "bincode", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "nym-id" +version = "1.21.1" +dependencies = [ + "nym-credential-storage", + "nym-credentials", + "thiserror 2.0.18", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "nym-ip-packet-requests" +version = "1.21.1" +dependencies = [ + "bincode", + "bytes 1.11.1", + "nym-bin-common", + "nym-crypto", + "nym-service-provider-requests-common", + "nym-sphinx", + "rand 0.8.6", + "semver", + "serde", + "thiserror 2.0.18", + "time", + "tokio-util 0.7.18", + "tracing", +] + +[[package]] +name = "nym-kkt-ciphersuite" +version = "1.21.1" +dependencies = [ + "num_enum", + "semver", + "strum 0.28.0", + "strum_macros 0.28.0", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-lp-data" +version = "1.21.1" +dependencies = [ + "bytes 1.11.1", + "num_enum", + "nym-common", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "nym-metrics" +version = "1.21.1" +dependencies = [ + "dashmap", + "lazy_static", + "prometheus", + "tracing", +] + +[[package]] +name = "nym-mixnet-client" +version = "1.21.1" +dependencies = [ + "dashmap", + "futures 0.3.31", + "nym-metrics", + "nym-noise", + "nym-sphinx", + "nym-task", + "strum 0.28.0", + "tokio 1.49.0", + "tokio-stream", + "tokio-util 0.7.18", + "tracing", +] + +[[package]] +name = "nym-mixnet-contract-common" +version = "1.21.1" +dependencies = [ + "bs58 0.5.1", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "humantime-serde", + "nym-contracts-common", + "schemars", + "semver", + "serde", + "serde_repr", + "thiserror 2.0.18", + "time", + "utoipa", +] + +[[package]] +name = "nym-multisig-contract-common" +version = "1.21.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "cw3", + "cw4", + "schemars", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-network-defaults" +version = "1.21.1" +dependencies = [ + "cargo_metadata 0.19.2", + "dotenvy", + "regex", + "schemars", + "serde", + "serde_json", + "tracing", + "url", + "utoipa", +] + +[[package]] +name = "nym-network-monitors-contract-common" +version = "1.21.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "schemars", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-node-families-contract-common" +version = "1.21.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-utils", + "nym-contracts-common", + "nym-mixnet-contract-common", + "schemars", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-node-requests" +version = "1.21.1" +dependencies = [ + "celes", + "humantime-serde", + "nym-bin-common", + "nym-crypto", + "nym-exit-policy", + "nym-kkt-ciphersuite", + "nym-noise-keys", + "nym-upgrade-mode-check", + "nym-wireguard-types", + "schemars", + "serde", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", + "thiserror 2.0.18", + "time", + "url", + "utoipa", +] + +[[package]] +name = "nym-noise" +version = "1.21.1" +dependencies = [ + "arc-swap", + "bytes 1.11.1", + "futures 0.3.31", + "nym-crypto", + "nym-noise-keys", + "pin-project", + "sha2 0.10.9", + "snow", + "strum 0.28.0", + "strum_macros 0.28.0", + "thiserror 2.0.18", + "tokio 1.49.0", + "tokio-util 0.7.18", + "tracing", +] + +[[package]] +name = "nym-noise-keys" +version = "1.21.1" +dependencies = [ + "nym-crypto", + "schemars", + "serde", + "utoipa", +] + +[[package]] +name = "nym-nonexhaustive-delayqueue" +version = "1.21.1" +dependencies = [ + "tokio 1.49.0", + "tokio-stream", + "tokio-util 0.7.18", + "wasmtimer", +] + +[[package]] +name = "nym-ordered-buffer" +version = "1.21.1" +dependencies = [ + "log", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-outfox" +version = "1.21.1" +dependencies = [ + "blake3", + "chacha20 0.9.1", + "chacha20poly1305 0.10.1", + "sphinx-packet", + "thiserror 2.0.18", + "x25519-dalek 2.0.1", + "zeroize", +] + +[[package]] +name = "nym-pemstore" +version = "1.21.1" +dependencies = [ + "pem", + "tracing", + "zeroize", +] + +[[package]] +name = "nym-performance-contract-common" +version = "1.21.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "nym-contracts-common", + "schemars", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-sdk" +version = "1.21.1" +dependencies = [ + "anyhow", + "async-trait", + "bincode", + "bip39", + "bytecodec", + "bytes 1.11.1", + "clap", + "dashmap", + "dirs 6.0.0", + "futures 0.3.31", + "http 1.4.0", + "httpcodec", + "log", + "nym-bandwidth-controller", + "nym-bin-common", + "nym-client-core", + "nym-credential-storage", + "nym-credential-utils", + "nym-credentials", + "nym-credentials-interface", + "nym-crypto", + "nym-gateway-requests", + "nym-http-api-client", + "nym-ip-packet-requests", + "nym-lp-data", + "nym-network-defaults", + "nym-ordered-buffer", + "nym-service-providers-common", + "nym-socks5-client-core", + "nym-socks5-requests", + "nym-sphinx", + "nym-sphinx-addressing", + "nym-statistics-common", + "nym-task", + "nym-topology", + "nym-validator-client", + "rand 0.8.6", + "semver", + "serde", + "tap", + "tempfile", + "thiserror 2.0.18", + "tokio 1.49.0", + "tokio-stream", + "tokio-util 0.7.18", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "url", + "uuid 1.20.0", + "zeroize", +] + +[[package]] +name = "nym-serde-helpers" +version = "1.21.1" +dependencies = [ + "base64 0.22.1", + "bs58 0.5.1", + "hex", + "serde", + "time", +] + +[[package]] +name = "nym-service-provider-requests-common" +version = "1.21.1" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-service-providers-common" +version = "1.21.1" +dependencies = [ + "async-trait", + "log", + "nym-bin-common", + "nym-sphinx-anonymous-replies", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-socks5-client-core" +version = "1.21.1" +dependencies = [ + "anyhow", + "dirs 6.0.0", + "futures 0.3.31", + "log", + "nym-bandwidth-controller", + "nym-client-core", + "nym-config", + "nym-contracts-common", + "nym-credential-storage", + "nym-mixnet-contract-common", + "nym-network-defaults", + "nym-service-providers-common", + "nym-socks5-proxy-helpers", + "nym-socks5-requests", + "nym-sphinx", + "nym-task", + "nym-validator-client", + "pin-project", + "rand 0.8.6", + "reqwest 0.13.4", + "schemars", + "serde", + "tap", + "thiserror 2.0.18", + "tokio 1.49.0", + "url", +] + +[[package]] +name = "nym-socks5-proxy-helpers" +version = "1.21.1" +dependencies = [ + "bytes 1.11.1", + "futures 0.3.31", + "log", + "nym-ordered-buffer", + "nym-socks5-requests", + "nym-task", + "tokio 1.49.0", + "tokio-util 0.7.18", +] + +[[package]] +name = "nym-socks5-requests" +version = "1.21.1" +dependencies = [ + "bincode", + "log", + "nym-exit-policy", + "nym-service-providers-common", + "nym-sphinx-addressing", + "serde", + "serde_json", + "tap", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-sphinx" +version = "1.21.1" +dependencies = [ + "nym-crypto", + "nym-metrics", + "nym-sphinx-acknowledgements", + "nym-sphinx-addressing", + "nym-sphinx-anonymous-replies", + "nym-sphinx-chunking", + "nym-sphinx-cover", + "nym-sphinx-forwarding", + "nym-sphinx-framing", + "nym-sphinx-params", + "nym-sphinx-routing", + "nym-sphinx-types", + "nym-topology", + "rand 0.8.6", + "rand_chacha 0.3.1", + "rand_distr", + "thiserror 2.0.18", + "tokio 1.49.0", + "tracing", +] + +[[package]] +name = "nym-sphinx-acknowledgements" +version = "1.21.1" +dependencies = [ + "nym-crypto", + "nym-pemstore", + "nym-sphinx-addressing", + "nym-sphinx-params", + "nym-sphinx-routing", + "nym-sphinx-types", + "nym-topology", + "rand 0.8.6", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "nym-sphinx-addressing" +version = "1.21.1" +dependencies = [ + "nym-crypto", + "nym-sphinx-types", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-sphinx-anonymous-replies" +version = "1.21.1" +dependencies = [ + "bs58 0.5.1", + "nym-crypto", + "nym-sphinx-addressing", + "nym-sphinx-params", + "nym-sphinx-routing", + "nym-sphinx-types", + "nym-topology", + "rand 0.8.6", + "thiserror 2.0.18", + "tracing", + "wasm-bindgen", +] + +[[package]] +name = "nym-sphinx-chunking" +version = "1.21.1" +dependencies = [ + "dashmap", + "log", + "nym-crypto", + "nym-metrics", + "nym-sphinx-addressing", + "nym-sphinx-params", + "nym-sphinx-types", + "rand 0.8.6", + "serde", + "thiserror 2.0.18", + "utoipa", + "wasmtimer", +] + +[[package]] +name = "nym-sphinx-cover" +version = "1.21.1" +dependencies = [ + "nym-crypto", + "nym-sphinx-acknowledgements", + "nym-sphinx-addressing", + "nym-sphinx-chunking", + "nym-sphinx-forwarding", + "nym-sphinx-params", + "nym-sphinx-routing", + "nym-sphinx-types", + "nym-topology", + "rand 0.8.6", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-sphinx-forwarding" +version = "1.21.1" +dependencies = [ + "nym-sphinx-addressing", + "nym-sphinx-anonymous-replies", + "nym-sphinx-params", + "nym-sphinx-types", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-sphinx-framing" +version = "1.21.1" +dependencies = [ + "bytes 1.11.1", + "cfg-if 1.0.4", + "nym-sphinx-acknowledgements", + "nym-sphinx-addressing", + "nym-sphinx-forwarding", + "nym-sphinx-params", + "nym-sphinx-types", + "thiserror 2.0.18", + "tokio-util 0.7.18", + "tracing", +] + +[[package]] +name = "nym-sphinx-params" +version = "1.21.1" +dependencies = [ + "nym-crypto", + "nym-sphinx-types", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-sphinx-routing" +version = "1.21.1" +dependencies = [ + "nym-sphinx-addressing", + "nym-sphinx-types", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-sphinx-types" +version = "1.21.1" +dependencies = [ + "nym-outfox", + "sphinx-packet", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-sqlx-pool-guard" +version = "1.21.1" +dependencies = [ + "proc_pidinfo", + "sqlx", + "tokio 1.49.0", + "tracing", + "windows 0.61.3", +] + +[[package]] +name = "nym-statistics-common" +version = "1.21.1" +dependencies = [ + "futures 0.3.31", + "log", + "nym-credentials-interface", + "nym-crypto", + "nym-metrics", + "nym-sphinx", + "nym-task", + "serde", + "serde_json", + "sha2 0.10.9", + "si-scale", + "strum 0.28.0", + "strum_macros 0.28.0", + "sysinfo 0.38.4", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "wasmtimer", +] + +[[package]] +name = "nym-task" +version = "1.21.1" +dependencies = [ + "cfg-if 1.0.4", + "futures 0.3.31", + "log", + "thiserror 2.0.18", + "tokio 1.49.0", + "tokio-util 0.7.18", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "nym-ticketbooks-merkle" +version = "1.21.1" +dependencies = [ + "nym-credentials-interface", + "nym-serde-helpers", + "rs_merkle", + "schemars", + "serde", + "sha2 0.10.9", + "time", + "utoipa", +] + +[[package]] +name = "nym-topology" +version = "1.21.1" +dependencies = [ + "async-trait", + "nym-api-requests", + "nym-crypto", + "nym-mixnet-contract-common", + "nym-sphinx-addressing", + "nym-sphinx-types", + "rand 0.8.6", + "reqwest 0.13.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tracing", +] + +[[package]] +name = "nym-upgrade-mode-check" +version = "1.21.1" +dependencies = [ + "jwt-simple", + "nym-crypto", + "nym-http-api-client", + "reqwest 0.13.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tracing", + "utoipa", +] + +[[package]] +name = "nym-validator-client" +version = "1.21.1" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bip32", + "bip39", + "colored", + "cosmrs", + "cosmwasm-std", + "cw-controllers", + "cw-utils", + "cw2", + "cw3", + "cw4", + "eyre", + "flate2", + "futures 0.3.31", + "itertools 0.14.0", + "nym-api-requests", + "nym-coconut-dkg-common", + "nym-compact-ecash", + "nym-config", + "nym-contracts-common", + "nym-ecash-contract-common", + "nym-group-contract-common", + "nym-http-api-client", + "nym-mixnet-contract-common", + "nym-multisig-contract-common", + "nym-network-defaults", + "nym-network-monitors-contract-common", + "nym-node-families-contract-common", + "nym-performance-contract-common", + "nym-serde-helpers", + "nym-vesting-contract-common", + "prost", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.10.9", + "tendermint-rpc", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "tracing", + "url", + "wasmtimer", + "zeroize", +] + +[[package]] +name = "nym-vesting-contract-common" +version = "1.21.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "nym-contracts-common", + "nym-mixnet-contract-common", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "nym-wasm-utils" +version = "1.21.1" +dependencies = [ + "futures 0.3.31", + "getrandom 0.2.17", + "gloo-net", + "gloo-utils", + "js-sys", + "tungstenite 0.20.1", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "nym-wireguard-types" +version = "1.21.1" +dependencies = [ + "base64 0.22.1", + "nym-crypto", + "serde", + "thiserror 2.0.18", + "x25519-dalek 2.0.1", + "zeroize", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if 1.0.4", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "ordered-float" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite 0.2.16", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "pairing" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" +dependencies = [ + "group", +] + +[[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.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a704eb390aafdc107b0e392f56a82b668e3a71366993b5340f5833fd62505e" +dependencies = [ + "lock_api 0.3.4", + "parking_lot_core 0.7.3", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api 0.4.14", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93f386bb233083c799e6e642a9d73db98c24a5deeb95ffc85bf281255dffc98" +dependencies = [ + "cfg-if 0.1.10", + "cloudabi", + "libc", + "redox_syscall 0.1.57", + "smallvec", + "winapi 0.3.9", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a5d4e9c205d2c1ae73b84aab6240e98218c0e72e63b50422cfb2d1ca952282" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle 2.6.1", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle 2.6.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pbkdf2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95f5254224e617595d2cc3cc73ff0a5eaf2637519e25f03388154e9378b6ffa" +dependencies = [ + "base64ct", + "crypto-mac 0.11.0", + "hmac 0.11.0", + "password-hash 0.2.1", + "sha2 0.9.9", +] + +[[package]] +name = "pbkdf2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271779f35b581956db91a3e55737327a03aa051e90b1c47aeb189508533adfd7" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "peg" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" +dependencies = [ + "peg-runtime", + "proc-macro2 1.0.106", + "quote 1.0.44", +] + +[[package]] +name = "peg-runtime" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" + +[[package]] +name = "pem" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb" +dependencies = [ + "base64 0.13.1", + "once_cell", + "regex", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "unicase", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", + "unicase", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[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 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "257b64915a082f7811703966789728173279bdebb956b143dbcd23f6f970a777" + +[[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 = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if 1.0.4", + "concurrent-queue", + "hermit-abi", + "pin-project-lite 0.2.16", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "poly1305" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug 0.3.1", + "universal-hash 0.4.0", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug 0.3.1", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "opaque-debug 0.3.1", + "universal-hash 0.5.1", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits 0.2.19", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2 1.0.106", + "syn 2.0.114", +] + +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term 0.7.0", + "unicode-width 0.1.14", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[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 1.0.106", + "quote 1.0.44", + "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 1.0.106", + "quote 1.0.44", + "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 1.0.106", + "quote 1.0.44", +] + +[[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 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" +dependencies = [ + "unicode-xid 0.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc_pidinfo" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29492a7b48a00ab80202528e235d2f80a04ccff3747540b4ec6881f2f2bc42d1" +dependencies = [ + "libc", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if 1.0.4", + "fnv", + "lazy_static", + "memchr", + "parking_lot 0.12.5", + "protobuf", + "thiserror 2.0.18", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes 1.11.1", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qr_code" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5520fbcd7da152a449261c5a533a1c7fad044e9e8aa9528cfec3f464786c7926" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes 1.11.1", + "cfg_aliases", + "pin-project-lite 0.2.16", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls 0.23.40", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio 1.49.0", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes 1.11.1", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring 0.17.14", + "rustc-hash 2.1.1", + "rustls 0.23.40", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" +dependencies = [ + "proc-macro2 0.4.30", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2 1.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" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c618c47cd3ebd209790115ab837de41425723956ad3ce2e6a7f09890947cacb9" +dependencies = [ + "cloudabi", + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "winapi 0.3.9", +] + +[[package]] +name = "rand" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d71dacdc3c88c1fde3885a3be3fbab9f35724e6ce99467f7d9c5026132184ca" +dependencies = [ + "autocfg 0.1.8", + "libc", + "rand_chacha 0.1.1", + "rand_core 0.4.2", + "rand_hc 0.1.0", + "rand_isaac", + "rand_jitter", + "rand_os", + "rand_pcg", + "rand_xorshift", + "winapi 0.3.9", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc 0.2.0", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.1", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556d3a1ca6600bfcbab7c7c91ccb085ac7fbbcd70e008a98742e7847f4f7bcef" +dependencies = [ + "autocfg 0.1.8", + "rand_core 0.3.1", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits 0.2.19", + "rand 0.8.6", +] + +[[package]] +name = "rand_hc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b40677c7be09ae76218dc623efbf7b18e34bced3f38883af07bb75630a21bc4" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_isaac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded997c9d5f13925be2a6fd7e66bf1872597f759fd9dd93513dd7e92e5a5ee08" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_jitter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1166d5c91dc97b88d1decc3285bb0a99ed84b05cfd0bc2341bdf2d43fc41e39b" +dependencies = [ + "libc", + "rand_core 0.4.2", + "winapi 0.3.9", +] + +[[package]] +name = "rand_os" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b75f676a1e053fc562eafbb47838d67c84801e38fc1ba459e8f180deabd5071" +dependencies = [ + "cloudabi", + "fuchsia-cprng", + "libc", + "rand_core 0.4.2", + "rdrand", + "winapi 0.3.9", +] + +[[package]] +name = "rand_pcg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf9b09b01790cfe0364f52bf32995ea3c39f4d2dd011eac241d2914146d0b44" +dependencies = [ + "autocfg 0.1.8", + "rand_core 0.4.2", +] + +[[package]] +name = "rand_xorshift" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf7e9e623549b0e21f6e97cf8ecf247c1a8fd2e8a992ae265314300b2455d5c" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec 0.7.6", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if 1.0.4", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits 0.2.19", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.10.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0718f81a8e14c4dbb3b34cf23dc6aaf9ab8a0dfec160c534b3dbca1aaa21f47c" +dependencies = [ + "base64 0.13.1", + "bytes 0.5.6", + "encoding_rs", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.3.1", + "hyper 0.13.10", + "hyper-rustls 0.21.0", + "hyper-tls 0.4.3", + "ipnet", + "js-sys", + "lazy_static", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite 0.2.16", + "rustls 0.18.1", + "serde", + "serde_urlencoded", + "tokio 0.2.25", + "tokio-rustls 0.14.1", + "tokio-socks 0.3.0", + "tokio-tls", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.20.0", + "winreg 0.7.0", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes 1.11.1", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite 0.2.16", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio 1.49.0", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite 0.2.16", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio 1.49.0", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite 0.2.16", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper 1.0.2", + "tokio 1.49.0", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle 2.6.1", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted 0.7.1", + "web-sys", + "winapi 0.3.9", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if 1.0.4", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "ripemd160" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eca4ecc81b7f313189bf73ce724400a07da2a6dac19588b03c8bd76a2dcc251" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "opaque-debug 0.3.1", +] + +[[package]] +name = "rkv" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f67a9dbc634fcd36a2d1d800ca818065dcf71a1d907dc35130c2d1552c6e1dc" +dependencies = [ + "arrayref", + "bincode", + "bitflags 2.10.0", + "id-arena", + "lazy_static", + "log", + "ordered-float 3.9.2", + "serde", + "serde_derive", + "thiserror 2.0.18", + "url", + "uuid 1.20.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rqrr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631" +dependencies = [ + "g2p", + "image", + "lru", +] + +[[package]] +name = "rs_merkle" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb09b49230ba22e8c676e7b75dfe2887dea8121f18b530ae0ba519ce442d2b21" +dependencies = [ + "sha2 0.10.9", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits 0.2.19", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "6.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "6.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "7.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74" +dependencies = [ + "sha2 0.10.9", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2 1.0.106", + "quote 1.0.44", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.114", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2 1.0.106", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher 1.0.2", + "toml 0.8.23", + "triomphe", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[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 = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1126dcf58e93cee7d098dbda643b5f92ed724f1f6a63007c1116eed6700c81" +dependencies = [ + "base64 0.12.3", + "log", + "ring 0.16.20", + "sct 0.6.1", + "webpki 0.21.4", +] + +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct 0.7.1", + "webpki 0.22.4", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring 0.17.14", + "rustls-webpki 0.101.7", + "sct 0.7.1", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring 0.17.14", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", + "jni 0.22.4", + "log", + "once_cell", + "rustls 0.23.40", + "rustls-native-certs 0.8.4", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring 0.17.14", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + +[[package]] +name = "salsa20" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0fbb5f676da676c260ba276a8f43a8dc67cf02d1438423aeb1c677a7212686" +dependencies = [ + "cipher 0.3.0", +] + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + +[[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 = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "serde_derive_internals", + "syn 2.0.114", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73d6d7c6311ebdbd9184ad6c4447b2f36337e327bda107d3ba9e3c374f9d325" +dependencies = [ + "hmac 0.12.1", + "pbkdf2 0.10.1", + "salsa20 0.9.0", + "sha2 0.10.9", +] + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash 0.5.0", + "pbkdf2 0.12.2", + "salsa20 0.10.2", + "sha2 0.10.9", +] + +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring 0.16.20", + "untrusted 0.7.1", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array 0.14.7", + "pkcs8 0.10.2", + "serdect 0.2.0", + "subtle 2.6.1", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" +dependencies = [ + "secp256k1-sys 0.8.2", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.6", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secrecy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9182278ed645df3477a9c27bfee0621c621aa16f6972635f7f795dae3d81070f" +dependencies = [ + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys 0.8.7", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-json-wasm" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05da0d153dd4595bdffd5099dc0e9ce425b205ee648eb93437ff7302af8c9a5" +dependencies = [ + "serde", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa 1.0.17", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.17", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa 1.0.17", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug 0.3.1", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd26bc0e7a2e3a7c959bc494caf58b72ee0c71d67704e9520f736ca7e4853ecf" +dependencies = [ + "block-buffer 0.7.3", + "byte-tools", + "digest 0.8.1", + "keccak 0.1.5", + "opaque-debug 0.2.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "si-scale" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72e7cd0744e007e382ba320435f1ed1ecd709409b4ebd5cfbc843d77b25a8aa" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote 1.0.44", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.14.3", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.3", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2 0.10.6", + "chacha20poly1305 0.10.1", + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "rustc_version", + "sha2 0.10.9", + "subtle 2.6.1", +] + +[[package]] +name = "socket2" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122e570113d28d773067fab24266b66753f6ea915758651696b6e35e49f88d6e" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "sphinx-packet" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c26f0c20d909fdda1c5d0ece3973127ca421984d55b000215df365e93722fc6e" +dependencies = [ + "aes 0.8.4", + "arrayref", + "blake2 0.8.1", + "bs58 0.5.1", + "byteorder", + "chacha", + "ctr", + "curve25519-dalek 4.1.3", + "digest 0.10.7", + "hkdf 0.12.4", + "hmac 0.12.1", + "lioness", + "rand 0.8.6", + "rand_distr", + "sha2 0.10.9", + "subtle 2.6.1", + "x25519-dalek 2.0.1", + "zeroize", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api 0.4.14", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "crc", + "crossbeam-queue", + "either", + "event-listener 5.4.1", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.13.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.40", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "time", + "tokio 1.49.0", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.114", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2 1.0.106", + "quote 1.0.44", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.114", + "tokio 1.49.0", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "bytes 1.11.1", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array 0.14.7", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "itoa 1.0.17", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "home", + "itoa 1.0.17", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "time", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bd81eb48f4c437cadc685403cad539345bf703d78e63707418431cecd4522b" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c85aa3f8ea653bfd3ddf25f7ee357ee4d204731f6aa9ad04002306f6e2774c" +dependencies = [ + "heck 0.3.3", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "subtle" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d67a5a62ba6e01cb2192ff309324cb4875d0c451d55fe2319433abe7a05a8ee" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "subtle-encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcb1ed7b8330c5eed5441052651dd7a12c75e2ed88f2ec024ae1fa3a5e59945" +dependencies = [ + "zeroize", +] + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "superboring" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efc6310a69b44420f3bf53d518077615b7d466cc57df7a80e404e7feb8c510f7" +dependencies = [ + "aes-gcm", + "aes-keywrap", + "getrandom 0.2.17", + "hmac-sha256", + "hmac-sha512", + "ml-dsa", + "rand 0.8.6", + "rsa", +] + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher 1.0.2", +] + +[[package]] +name = "syn" +version = "0.15.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "unicode-xid 0.1.0", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synchronoise" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" +dependencies = [ + "crossbeam-queue", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.29.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd727fc423c2060f6c92d9534cef765c65a6ed3f428a03d7def74a8c4348e666" +dependencies = [ + "cfg-if 1.0.4", + "core-foundation-sys 0.8.7", + "libc", + "ntapi", + "once_cell", + "rayon", + "winapi 0.3.9", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendermint" +version = "0.40.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc997743ecfd4864bbca8170d68d9b2bee24653b034210752c2d883ef4b838b1" +dependencies = [ + "bytes 1.11.1", + "digest 0.10.7", + "ed25519 2.2.3", + "ed25519-consensus", + "flex-error", + "futures 0.3.31", + "k256", + "num-traits 0.2.19", + "once_cell", + "prost", + "ripemd", + "serde", + "serde_bytes", + "serde_json", + "serde_repr", + "sha2 0.10.9", + "signature 2.2.0", + "subtle 2.6.1", + "subtle-encoding", + "tendermint-proto", + "time", + "zeroize", +] + +[[package]] +name = "tendermint-config" +version = "0.40.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069d1791f9b02a596abcd26eb72003b2e9906c6169a60fa82ffc080dd3a43fda" +dependencies = [ + "flex-error", + "serde", + "serde_json", + "tendermint", + "toml 0.8.23", + "url", +] + +[[package]] +name = "tendermint-proto" +version = "0.40.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c40e13d39ca19082d8a7ed22de7595979350319833698f8b1080f29620a094" +dependencies = [ + "bytes 1.11.1", + "flex-error", + "prost", + "serde", + "serde_bytes", + "subtle-encoding", + "time", +] + +[[package]] +name = "tendermint-rpc" +version = "0.40.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e0569a4b4cc42ff00df5a665be2858a39ff79df4790b176f1cd0e169bc0fc2" +dependencies = [ + "async-trait", + "bytes 1.11.1", + "flex-error", + "futures 0.3.31", + "getrandom 0.2.17", + "peg", + "pin-project", + "rand 0.8.6", + "reqwest 0.11.27", + "semver", + "serde", + "serde_bytes", + "serde_json", + "subtle 2.6.1", + "subtle-encoding", + "tendermint", + "tendermint-config", + "tendermint-proto", + "thiserror 1.0.69", + "time", + "tokio 1.49.0", + "tracing", + "url", + "uuid 1.20.0", + "walkdir", +] + +[[package]] +name = "term" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0863a3345e70f61d613eab32ee046ccd1bcc5f9105fe402c61fcd0c13eeb8b5" +dependencies = [ + "dirs 2.0.2", + "winapi 0.3.9", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi 0.3.9", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "thread-id" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2010d27add3f3240c1fef7959f46c814487b216baee662af53be645ba7831c07" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if 1.0.4", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "timer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31d42176308937165701f50638db1c31586f183f1aab416268216577aec7306b" +dependencies = [ + "chrono", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "bytemuck", + "cfg-if 1.0.4", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "tokio" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6703a273949a90131b290be1fe7b039d0fc884aa1935860dfcbe056f28cd8092" +dependencies = [ + "bytes 0.5.6", + "fnv", + "futures-core", + "iovec", + "lazy_static", + "libc", + "memchr", + "mio 0.6.23", + "mio-named-pipes", + "mio-uds", + "num_cpus", + "pin-project-lite 0.1.12", + "signal-hook-registry", + "slab", + "tokio-macros 0.2.6", + "winapi 0.3.9", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes 1.11.1", + "libc", + "mio 1.1.1", + "parking_lot 0.12.5", + "pin-project-lite 0.2.16", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros 2.6.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite 0.2.16", + "tokio 1.49.0", +] + +[[package]] +name = "tokio-macros" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e44da00bfc73a25f814cd8d7e57a68a5c31b74b3152a0a1d1f590c97ed06265a" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 1.0.109", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio 1.49.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12831b255bcfa39dc0436b01e19fea231a37db570686c06ee72c423479f889a" +dependencies = [ + "futures-core", + "rustls 0.18.1", + "tokio 0.2.25", + "webpki 0.21.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls 0.20.9", + "tokio 1.49.0", + "webpki 0.22.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio 1.49.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio 1.49.0", +] + +[[package]] +name = "tokio-socks" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d611fd5d241872372d52a0a3d309c52d0b95a6a67671a6c8f7ab2c4a37fb2539" +dependencies = [ + "bytes 0.4.12", + "either", + "futures 0.3.31", + "thiserror 1.0.69", + "tokio 0.2.25", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio 1.49.0", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite 0.2.16", + "tokio 1.49.0", + "tokio-util 0.7.18", +] + +[[package]] +name = "tokio-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a70f4fcd7b3b24fb194f837560168208f669ca8cb70d0c4b862944452396343" +dependencies = [ + "native-tls", + "tokio 0.2.25", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "rustls 0.21.12", + "tokio 1.49.0", + "tokio-rustls 0.24.1", + "tungstenite 0.20.1", + "webpki-roots 0.25.4", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.40", + "rustls-pki-types", + "tokio 1.49.0", + "tokio-rustls 0.26.4", + "tungstenite 0.26.2", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571da51182ec208780505a32528fc5512a8fe1443ab960b3f2f3ef093cd16930" +dependencies = [ + "bytes 0.5.6", + "futures-core", + "futures-sink", + "log", + "pin-project-lite 0.1.12", + "tokio 0.2.25", +] + +[[package]] +name = "tokio-util" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8242891f2b6cbef26a2d7e8605133c2c554cd35b3e4948ea892d6d68436499" +dependencies = [ + "bytes 0.5.6", + "futures-core", + "futures-sink", + "log", + "pin-project-lite 0.1.12", + "tokio 0.2.25", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes 1.11.1", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite 0.2.16", + "slab", + "tokio 1.49.0", +] + +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio 1.49.0", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[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.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[package]] +name = "toml" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[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.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.14", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite 0.2.16", + "sync_wrapper 1.0.2", + "tokio 1.49.0", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.10.0", + "bytes 1.11.1", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite 0.2.16", + "tokio 1.49.0", + "tokio-util 0.7.18", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite 0.2.16", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "trackable" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98abb9e7300b9ac902cc04920945a874c1973e08c310627cc4458c04b70dd32" +dependencies = [ + "trackable 1.3.0", + "trackable_derive", +] + +[[package]] +name = "trackable" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15bd114abb99ef8cee977e517c8f37aee63f184f2d08e3e6ceca092373369ae" +dependencies = [ + "trackable_derive", +] + +[[package]] +name = "trackable_derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb235c5847e2f82cfe0f07eb971d1e5f6804b18dac2ae16349cc604380f82f" +dependencies = [ + "quote 1.0.44", + "syn 1.0.109", +] + +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes 1.11.1", + "data-encoding", + "http 0.2.12", + "httparse", + "log", + "rand 0.8.6", + "rustls 0.21.12", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", + "webpki-roots 0.24.0", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes 1.11.1", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "rustls 0.23.40", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + +[[package]] +name = "typemap-ors" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68c24b707f02dd18f1e4ccceb9d49f2058c2fb86384ef9972592904d7a28867" +dependencies = [ + "unsafe-any-ors", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi 0.3.9", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b2c654932e3e4f9196e69d08fdf7cfd718e1dc6f66b347e6024a0c961402" +dependencies = [ + "generic-array 0.14.7", + "subtle 2.6.1", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle 2.6.1", +] + +[[package]] +name = "unsafe-any-ors" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a303d30665362d9680d7d91d78b23f5f899504d4f08b3c4cf08d055d87c0ad" +dependencies = [ + "destructure_traitobject", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ur" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010f24a953db5d22d0010969ca3bbf40b3857b89f47c0f7be0da4c2d7ded0760" +dependencies = [ + "bitcoin_hashes 0.12.0", + "crc", + "minicbor", + "phf 0.11.3", + "rand_xoshiro", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher 1.0.2", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", + "serde", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v4l" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" +dependencies = [ + "bitflags 1.3.2", + "libc", + "v4l2-sys-mit", +] + +[[package]] +name = "v4l2-sys-mit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" +dependencies = [ + "bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits 0.2.19", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "8.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27d6bdd219887a9eadd19e1c34f32e47fa332301184935c6d9bca26f3cca525" +dependencies = [ + "anyhow", + "cargo_metadata 0.18.1", + "cfg-if 1.0.4", + "regex", + "rustc_version", + "rustversion", + "time", +] + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[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.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasix" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe" +dependencies = [ + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if 1.0.4", + "once_cell", + "rustversion", + "serde", + "serde_json", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if 1.0.4", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote 1.0.44", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures 0.3.31", + "js-sys", + "parking_lot 0.12.5", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.3", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.3", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.10.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" +dependencies = [ + "rustix 1.1.3", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791c58fdeec5406aa37169dd815327d1e47f334219b523444bc26d70ceb4c34e" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2 1.0.106", + "quick-xml", + "quote 1.0.44", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" +dependencies = [ + "core-foundation 0.10.1", + "jni 0.21.1", + "log", + "ndk-context", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring 0.16.20", + "untrusted 0.7.1", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f20dea7535251981a9670857150d571846545088359b28e4951d350bdaf179f" +dependencies = [ + "webpki 0.21.4", +] + +[[package]] +name = "webpki-roots" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b291546d5d9d1eab74f069c77749f2cb8504a12caa20f0f2de93ddbf6f411888" +dependencies = [ + "rustls-webpki 0.101.7", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec 0.7.6", + "bitflags 2.10.0", + "cfg-if 1.0.4", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec 0.7.6", + "bit-set", + "bit-vec 0.8.0", + "bitflags 2.10.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap 2.13.0", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec 0.7.6", + "ash", + "bit-set", + "bitflags 2.10.0", + "block", + "bytemuck", + "cfg-if 1.0.4", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal 0.32.0", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float 5.1.0", + "parking_lot 0.12.5", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.10.0", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "winreg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if 1.0.4", + "windows-sys 0.48.0", +] + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml 1.0.6+spec-1.1.0", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid 0.2.6", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "ws2_32-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.3", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637ff90c9540fa3073bb577e65033069e4bae7c79d49d74aa3ffdf5342a53217" +dependencies = [ + "curve25519-dalek 2.1.3", + "rand_core 0.5.1", + "zeroize", +] + +[[package]] +name = "x25519-dalek" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a0c105152107e3b96f6a00a65e86ce82d9b125230e1c4302940eca58ff71f4f" +dependencies = [ + "curve25519-dalek 3.2.0", + "rand_core 0.5.1", + "zeroize", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.10.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.1", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.3", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid 1.20.0", + "windows-sys 0.61.2", + "winnow 0.7.14", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.14", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "441a0064125265655bccc3a6af6bef56814d9277ac83fce48b1cd7e160b80eac" +dependencies = [ + "quick-xml", + "serde", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[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 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "zip" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ab48844d61251bb3835145c521d88aa4031d7139e8485990f60ca911fa0815" +dependencies = [ + "byteorder", + "crc32fast", + "thiserror 1.0.69", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core 0.5.1", +] + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.14", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "serde", + "syn 2.0.114", + "winnow 0.7.14", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..264dd6b1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,186 @@ +[package] +name = "grim" +version = "0.3.6" +authors = ["Ardocrat "] +description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and the Nym mixnet handled for you." +license = "Apache-2.0" +repository = "https://code.gri.mw/GUI/grim" +keywords = [ "crypto", "grin", "mimblewimble", "nostr" ] +edition = "2024" +build = "build.rs" + +[[bin]] +name = "goblin" +path = "src/main.rs" + +[lib] +name="grim" +crate-type = ["rlib"] + +# Desktop/CI release binaries ship stripped of debug symbols — the nym + nostr + +# grin tree leaves a large symbol table that's dead weight for users (~16 MB on +# Linux). opt-level stays at the default 3 for wallet/runtime speed. +[profile.release] +strip = true + +[profile.release-apk] +inherits = "release" +strip = true +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" + +[dependencies] +log = "0.4.27" + +# node +grin_api = { path = "node/api" } +grin_chain = { path = "node/chain" } +grin_config = { path = "node/config" } +grin_core = { path = "node/core" } +grin_p2p = { path = "node/p2p" } +grin_servers = { path = "node/servers" } +grin_keychain = { path = "node/keychain" } +grin_util = { path = "node/util" } + +# wallet +grin_wallet_impls = { path = "wallet/impls" } +grin_wallet_api = { path = "wallet/api"} +grin_wallet_libwallet = { path = "wallet/libwallet" } +grin_wallet_util = { path = "wallet/util" } +grin_wallet_controller = { path = "wallet/controller" } + +## ui +egui = { version = "0.33.3", default-features = false } +egui_extras = { version = "0.33.3", features = ["image", "svg"] } +egui-async = "0.3.4" +rust-i18n = "3.1.5" + +## other +log4rs = "1.4.0" +anyhow = "1.0.97" +pin-project = "1.1.10" +backtrace = "0.3.76" +thiserror = "2.0.18" +futures = "0.3.31" +dirs = "6.0.0" +sys-locale = "0.3.2" +chrono = "0.4.43" +parking_lot = "0.12.3" +lazy_static = "1.5.0" +toml = "0.9.11+spec-1.1.0" +serde = "1.0.228" +local-ip-address = "0.6.9" +url = "2.5.8" +rand = "0.9.2" +serde_derive = "1.0.228" +serde_json = "1.0.149" +tokio = { version = "1.49.0", features = ["full"] } +image = "0.25.9" +rqrr = "0.10.1" +qrcodegen = "1.8.0" +qrcode = "0.14.1" +ur = "0.4.1" +gif = "0.14.1" +rkv = "0.20.0" +usvg = "0.45.1" +ring = "0.16.20" +hyper = { version = "1.6.0", features = ["full"], package = "hyper" } +hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy"] } +http-body-util = "0.1.3" +bytes = "1.11.0" +hyper-socks2 = "0.9.1" +hyper-proxy2 = "0.1.0" +hyper-tls = "0.6.0" +async-std = "1.13.2" +uuid = { version = "0.8.2", features = ["v4"] } +num-bigint = "0.4.6" + +## nostr +nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] } +nostr-relay-pool = "0.44" +async-wsocket = "0.13" +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } +regex = "1" +base64 = "0.22" +hex = "0.4" +## HTTP client routed through the local Nym SOCKS5 sidecar (rustls, no native +## TLS so it cross-compiles to Android; `socks` so every request — NIP-05, +## price, avatars — goes over the mixnet, never clearnet). +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks"] } +## SOCKS5 TCP dialer for the nostr relay WebSocket transport over the mixnet. +tokio-socks = "0.5" + +## rustls is pulled by both our TLS (tungstenite/reqwest, ring) and nym-sdk +## (aws-lc-rs); with two providers present rustls 0.23 can't auto-pick a default, +## so we install ring explicitly at startup (see lib.rs). Direct dep just to make +## `rustls::crypto::ring::default_provider()` reachable. +rustls = { version = "0.23", features = ["ring"] } + +## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary). We +## run the SDK's SOCKS5 client on an internal tokio task exposing 127.0.0.1:1080, +## the same loopback seam the transport already dials. Path dep: the local nym +## checkout carries our Android webpki-roots patch. +nym-sdk = { path = "../nym/sdk/rust/nym-sdk" } + +## NIP-98 payload hashing +sha2 = "0.10.8" + +## stratum server +tokio-old = { version = "0.2", features = ["full"], package = "tokio" } +tokio-util-old = { version = "0.2", features = ["codec"], package = "tokio-util" } + +[target.'cfg(target_os = "linux")'.dependencies] +nokhwa = { version = "0.10.10", default-features = false, features = ["input-v4l"] } + +[target.'cfg(target_os = "windows")'.dependencies] +nokhwa = { version = "0.10.10", default-features = false, features = ["input-msmf"] } + +[target.'cfg(target_os = "macos")'.dependencies] +nokhwa = { version = "0.10.10", default-features = false, features = ["input-avfoundation", "output-threaded"] } + +[target.'cfg(not(target_os = "android"))'.dependencies] +env_logger = "0.11.3" +winit = { version = "0.30.12" } +wgpu = { version = "27.0.1" } +eframe = { version = "0.33.2", features = ["wgpu"] } +arboard = "3.2.0" +rfd = "0.17.2" +interprocess = { version = "2.2.1", features = ["tokio"] } + +## native-tls (via hyper-tls) uses OpenSSL only on Linux/Android. Upstream Grim +## got a vendored, statically-linked OpenSSL for free through arti's `static` +## feature; dropping arti for Nym took that with it, breaking Android/cross +## builds (no system OpenSSL for the target) and leaving desktop dynamically +## linked to libssl. Restore the vendored build for exactly those two targets so +## each is self-contained. Windows (SChannel) and macOS (Security.framework) +## don't use OpenSSL at all, so they must NOT pull it — building openssl-src +## there is both pointless and fragile (the Windows MSVC runner's bash perl is +## missing modules its Configure needs). +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +openssl = { version = "0.10", features = ["vendored"] } + +[target.'cfg(target_os = "android")'.dependencies] +android_logger = "0.15.0" +jni = "0.21.1" +android-activity = { version = "0.6.0", features = ["game-activity"] } +winit = { version = "0.30.12", features = ["android-game-activity"] } +eframe = { version = "0.33.2", default-features = false, features = ["glow", "android-game-activity"] } + +[build-dependencies] +built = "0.8.0" + +# Windows hosts only: embed the Goblin icon (wix/Product.ico) into goblin.exe via +# build.rs. Not compiled on Linux/macOS/Android hosts, so other builds are +# unaffected. +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" + +[dev-dependencies] +nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] } +tokio = { version = "1.49.0", features = ["full"] } +base64 = "0.22" +sha2 = "0.10" +hex = "0.4" +serde_yaml = "0.9" diff --git a/Goblin-Banner.png b/Goblin-Banner.png new file mode 100644 index 00000000..fb27253b Binary files /dev/null and b/Goblin-Banner.png differ diff --git a/Goblin-Transport-Overview.pdf b/Goblin-Transport-Overview.pdf new file mode 100644 index 00000000..adcb4872 Binary files /dev/null and b/Goblin-Transport-Overview.pdf differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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/README.md b/README.md new file mode 100644 index 00000000..9f631b25 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +

+ Goblin +

+ +# Goblin + +Goblin is a private, pay-by-username wallet for [GRIN ツ](https://grin.mw) — confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain. + +Instead of passing slatepack files back and forth, you **pay a `username` (or an `npub`)** and the payment is delivered for you as an **end-to-end encrypted message over [nostr](https://github.com/nostr-protocol/nips), routed through the [Nym mixnet](https://nym.com)**. Relays only ever see ciphertext — never the amount, the sender, or the recipient — and the mixnet hides who is talking to whom at the network layer. + +Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN node/wallet engine and layers a Nostr-native, mobile-first payments experience on top. + +## What it does + +- **Send to people** — pay a `username` or `npub`; the GRIN slatepack travels as a [NIP-17](https://nips.nostr.com/17) gift-wrapped DM ([kind 1059](https://nostrbook.dev/kinds/1059)) over the Nym mixnet and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once. +- **Manual slatepacks too** — when you need to pay or get paid without a handle, **Settings → Wallet → Slatepacks** exposes the classic by-hand flow: create a slatepack to send, or paste one to receive, finalize, or pay. +- **In-app identity** — a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `name` comes from the goblin.st identity service. +- **Private by construction** — GRIN's address-less, confidential chain; your payments and identity (nostr relays, NIP-05 lookups, price) are routed through the [Nym mixnet](https://nym.com), so who-pays-whom never touches the clear net. The GRIN node connection — block sync and broadcasting your transaction — is direct: public chain data, the same for everyone, and not tied to your identity. Keys, names and history stay on your device. +- **Configurable amount pairing** — show balances against a world currency, Bitcoin, or sats (rates fetched over the mixnet), or turn the preview off. +- **Cross-platform** — Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui). + +## How a payment travels + +``` + you ──slatepack──▶ NIP-17 gift wrap (kind 1059, NIP-44 encrypted) + │ + Nym mixnet (5-hop) + │ + ┌─────────────┴─────────────┐ + your relays recipient's DM relays (kind 10050) + └─────────────┬─────────────┘ + ▼ + recipient ◀──unwrap, verify seal author, apply slatepack +``` + +The wrap is [NIP-44](https://nips.nostr.com/44)-encrypted, and delivery uses the recipient's DM relay list ([kind 10050](https://nostrbook.dev/kinds/10050)). + +Both parties only need one relay in common. The default set is the Goblin relay plus large public relays (`relay.damus.io`, `nos.lol`), and the set is editable in **Settings → Relays**. + +## Build + +### Desktop (Linux / macOS / Windows) + +Goblin links the [Nym mixnet](https://nym.com) SDK **in-process** — the wallet is a single self-contained binary, no sidecar. The SDK builds from a sibling `../nym` checkout (a pinned nym tree with a small Android TLS patch): + +``` +git clone --branch goblin https://git.us-ea.st/GRIN/nym ../nym +git submodule update --init --recursive +cargo build --release +./target/release/goblin +``` + +Goblin's identity and payment traffic — nostr relays, NIP-05 lookups and price fetches — is routed over the mixnet through a network requester (the default is baked into `NETWORK_REQUESTER` in `src/nym/sidecar.rs`); the SDK's SOCKS5 listener is run in-process on `127.0.0.1:1080`. If something is already listening there, Goblin reuses it. The GRIN node connection (block sync and transaction broadcast) is **not** mixed — it connects directly, as it carries only public chain data that isn't linked to your wallet. + +### Android + +Install the Android SDK / NDK, then from the repo root: + +``` +./scripts/android.sh build|release v7|v8|x86 +``` + +`v7`/`v8`/`x86` is the device CPU architecture for `build`; for `release` pass a version in `major.minor.patch` form. + +## Identity service (`goblin-nip05d`) + +The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration and release (names are never transferred — on a key rotation you release the old name and re-register, or import your existing identity). The wallet is fully usable — and fully anonymous — without it. Avatars aren't stored or served — clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark). + +## License + +Apache License v2.0. + +## Credits + +🤖 Built with AI pair-programming assistance (Claude) + +The underlying cross-platform GRIN wallet engine is the upstream **Grim** project. diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..04c0bd61 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,114 @@ +plugins { + id 'com.android.application' +} + +android { + compileSdk = 36 + ndkVersion '29.0.14206865' + buildToolsVersion = '36.1.0' + + defaultConfig { + applicationId "st.goblin.wallet" + minSdk 24 + targetSdk 36 + versionCode 5 + versionName "0.3.6" + } + + lint { + checkReleaseBuilds false + } + + def keystorePropertiesFile = rootProject.file("keystore.properties") + def keystoreProperties = new Properties() + if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + } + } + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + if (keystorePropertiesFile.exists()) { + signedRelease { + initWith release + signingConfig signingConfigs.release + } + } + debug { + minifyEnabled false + debuggable true + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + namespace 'mw.gri.android' + + flavorDimensions "mode" + + productFlavors { + ci { + dimension "mode" + } + local { + dimension "mode" + } + } + + applicationVariants.all { variant -> + def flavor = variant.productFlavors[0].name + + // The ci branch reads the private-mirror properties at configuration time, + // which runs for every variant — only enter it when the mirror is configured. + if (flavor == "ci" && project.hasProperty("mavenHost")) { + repositories { + maven { + credentials { + username "$mavenUser" + password "$mavenPassword" + } + url "$mavenHost/repository/maven-central/" + allowInsecureProtocol = true + } + maven { + credentials { + username "$mavenUser" + password "$mavenPassword" + } + url "$mavenHost/repository/google-android-maven/" + allowInsecureProtocol = true + } + } + } else if (flavor == "local") { + repositories { + google() + mavenCentral() + } + } + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.7.0' + + // To use the Games Activity library + implementation "androidx.games:games-activity:2.0.2" + + // Android Camera + implementation 'androidx.camera:camera-core:1.5.1' + implementation 'androidx.camera:camera-camera2:1.5.1' + implementation 'androidx.camera:camera-lifecycle:1.5.1' +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1321f03a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..43aa9f1b Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/mw/gri/android/BackgroundService.java b/android/app/src/main/java/mw/gri/android/BackgroundService.java new file mode 100644 index 00000000..f0df3b11 --- /dev/null +++ b/android/app/src/main/java/mw/gri/android/BackgroundService.java @@ -0,0 +1,239 @@ +package mw.gri.android; + +import android.annotation.SuppressLint; +import android.app.*; +import android.content.Context; +import android.content.Intent; +import android.os.*; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import java.util.List; + +import static android.app.Notification.EXTRA_NOTIFICATION_ID; + +public class BackgroundService extends Service { + private static final String TAG = BackgroundService.class.getSimpleName(); + + private PowerManager.WakeLock mWakeLock; + + private final Handler mHandler = new Handler(Looper.getMainLooper()); + private boolean mStopped = false; + + private static final int NOTIFICATION_ID = 1; + private NotificationCompat.Builder mNotificationBuilder; + + private String mNotificationContentText = ""; + private Boolean mCanStart = null; + private Boolean mCanStop = null; + + public static final String ACTION_START_NODE = "start_node"; + public static final String ACTION_STOP_NODE = "stop_node"; + + private final Runnable mUpdateSyncStatus = new Runnable() { + @SuppressLint("RestrictedApi") + @Override + public void run() { + if (mStopped) { + return; + } + // Update sync status at notification. + String syncStatusText = getSyncStatusText(); + boolean textChanged = !mNotificationContentText.equals(syncStatusText); + if (textChanged) { + mNotificationContentText = syncStatusText; + mNotificationBuilder.setContentText(mNotificationContentText); + mNotificationBuilder.setStyle(new NotificationCompat.BigTextStyle().bigText(mNotificationContentText)); + } + + // Send broadcast to MainActivity if exit from the app is needed after node stop. + if (exitAppAfterNodeStop()) { + sendBroadcast(new Intent(MainActivity.STOP_APP_ACTION)); + mStopped = true; + } + + if (!mStopped) { + boolean canStart = canStartNode(); + boolean canStop = canStopNode(); + + boolean buttonsChanged = mCanStart == null || mCanStop == null || + mCanStart != canStart || mCanStop != canStop; + mCanStart = canStart; + mCanStop = canStop; + if (buttonsChanged) { + mNotificationBuilder.mActions.clear(); + + // Set up buttons to start/stop node. + Intent startStopIntent = new Intent(BackgroundService.this, NotificationActionsReceiver.class); + if (Build.VERSION.SDK_INT > 25) { + startStopIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID); + } + if (canStart) { + startStopIntent.setAction(ACTION_START_NODE); + PendingIntent i = PendingIntent + .getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); + mNotificationBuilder.addAction(R.drawable.ic_start, getStartText(), i); + } else if (canStop) { + startStopIntent.setAction(ACTION_STOP_NODE); + PendingIntent i = PendingIntent + .getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); + mNotificationBuilder.addAction(R.drawable.ic_stop, getStopText(), i); + } + } + + // Update notification. + if (textChanged || buttonsChanged) { + NotificationManager manager = getSystemService(NotificationManager.class); + manager.notify(NOTIFICATION_ID, mNotificationBuilder.build()); + } + + // Repeat notification update. + mHandler.postDelayed(this, 1000); + } + } + }; + + @SuppressLint({"WakelockTimeout", "UnspecifiedRegisterReceiverFlag"}) + @Override + public void onCreate() { + if (mStopped) { + return; + } + + // Prevent CPU to sleep at background. + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); + mWakeLock.acquire(); + + // Create channel to show notifications. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel notificationChannel = new NotificationChannel( + TAG, TAG, NotificationManager.IMPORTANCE_LOW + ); + + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel(notificationChannel); + } + + // Show notification with sync status. + Intent i = getPackageManager().getLaunchIntentForPackage(this.getPackageName()); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE); + try { + mNotificationBuilder = new NotificationCompat.Builder(this, TAG) + .setContentTitle(this.getSyncTitle()) + .setContentText(this.getSyncStatusText()) + .setStyle(new NotificationCompat.BigTextStyle().bigText(this.getSyncStatusText())) + .setSmallIcon(R.drawable.ic_stat_name) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setContentIntent(pendingIntent); + } catch (UnsatisfiedLinkError e) { + return; + } + Notification notification = mNotificationBuilder.build(); + + // Start service at foreground state to prevent killing by system. + startForeground(NOTIFICATION_ID, notification); + + // Update sync status at notification. + mHandler.post(mUpdateSyncStatus); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_STICKY; + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + onStop(); + super.onTaskRemoved(rootIntent); + } + + @Override + public void onDestroy() { + onStop(); + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + public void onStop() { + mStopped = true; + + // Stop updating the notification. + mHandler.removeCallbacks(mUpdateSyncStatus); + clearNotification(); + + // Remove service from foreground state. + stopForeground(Service.STOP_FOREGROUND_REMOVE); + + // Release wake lock to allow CPU to sleep at background. + if (mWakeLock != null && mWakeLock.isHeld()) { + mWakeLock.release(); + mWakeLock = null; + } + } + + // Remove notification. + private void clearNotification() { + NotificationManager notificationManager = getSystemService(NotificationManager.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.deleteNotificationChannel(TAG); + } + notificationManager.cancel(NOTIFICATION_ID); + } + + // Start the service. + public static void start(Context c) { + if (!isServiceRunning(c)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ContextCompat.startForegroundService(c, new Intent(c, BackgroundService.class)); + } else { + c.startService(new Intent(c, BackgroundService.class)); + } + } + } + + // Stop the service. + public static void stop(Context context) { + context.stopService(new Intent(context, BackgroundService.class)); + } + + // Check if service is running. + private static boolean isServiceRunning(Context context) { + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + List services = activityManager.getRunningServices(Integer.MAX_VALUE); + + for (ActivityManager.RunningServiceInfo runningServiceInfo : services) { + if (runningServiceInfo.service.getClassName().equals(BackgroundService.class.getName())) { + return true; + } + } + + return false; + } + + // Get sync status text for notification. + private native String getSyncStatusText(); + // Get sync title text for notification. + private native String getSyncTitle(); + + // Get start text for notification. + private native String getStartText(); + // Get stop text for notification. + private native String getStopText(); + + // Check if start node is possible. + private native boolean canStartNode(); + // Check if stop node is possible. + private native boolean canStopNode(); + + // Check if app from the app is needed after node stop. + private native boolean exitAppAfterNodeStop(); +} \ No newline at end of file diff --git a/android/app/src/main/java/mw/gri/android/FileProvider.java b/android/app/src/main/java/mw/gri/android/FileProvider.java new file mode 100644 index 00000000..c7bd8b08 --- /dev/null +++ b/android/app/src/main/java/mw/gri/android/FileProvider.java @@ -0,0 +1,7 @@ +package mw.gri.android; + +public class FileProvider extends androidx.core.content.FileProvider { + public FileProvider() { + super(R.xml.paths); + } +} diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java new file mode 100644 index 00000000..e3903c60 --- /dev/null +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -0,0 +1,632 @@ +package mw.gri.android; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.*; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.net.Uri; +import android.os.*; +import android.os.Process; +import android.provider.Settings; +import android.system.ErrnoException; +import android.system.Os; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.camera.core.*; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.core.graphics.Insets; +import androidx.core.view.DisplayCutoutCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import com.google.androidgamesdk.GameActivity; +import com.google.androidgamesdk.gametextinput.State; +import com.google.common.util.concurrent.ListenableFuture; + +import java.io.*; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static android.content.ClipDescription.MIMETYPE_TEXT_HTML; +import static android.content.ClipDescription.MIMETYPE_TEXT_PLAIN; + +public class MainActivity extends GameActivity { + public static String STOP_APP_ACTION = "STOP_APP"; + + private static final int NOTIFICATIONS_PERMISSION_CODE = 1; + private static final int CAMERA_PERMISSION_CODE = 2; + + static { + System.loadLibrary("grim"); + } + + private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent i) { + if (Objects.equals(i.getAction(), STOP_APP_ACTION)) { + exit(); + } + } + }; + + private final ImageAnalysis mImageAnalysis = new ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build(); + + private ListenableFuture mCameraProviderFuture = null; + private ProcessCameraProvider mCameraProvider = null; + private ExecutorService mCameraExecutor = null; + private boolean mUseBackCamera = true; + + private ActivityResultLauncher mFilePickResult = null; + private ActivityResultLauncher mOpenFilePermissionsResult = null; + private ActivityResultLauncher mFileSaveResult = null; + // Source path (in the share cache) staged by Rust for the next saveFile(). + private String mPendingSavePath = null; + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + @Override + protected void onCreate(Bundle savedInstanceState) { + // Check if activity was launched to exclude from recent apps on exit. + if ((getIntent().getFlags() & Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) != 0) { + super.onCreate(null); + finish(); + return; + } + + // Clear cache on start. + if (savedInstanceState == null && getExternalCacheDir() != null) { + Utils.deleteDirectoryContent(new File(getExternalCacheDir().getPath()), false); + } + + // Setup environment variables for native code. + try { + Os.setenv("HOME", Objects.requireNonNull(getExternalFilesDir("")).getPath(), true); + Os.setenv("XDG_CACHE_HOME", Objects.requireNonNull(getExternalCacheDir()).getPath(), true); + Os.setenv("ARTI_FS_DISABLE_PERMISSION_CHECKS", "true", true); + Os.setenv("NATIVE_LIBS_DIR", getApplicationInfo().nativeLibraryDir, true); + } catch (ErrnoException e) { + throw new RuntimeException(e); + } + + super.onCreate(null); + + // Register receiver to finish activity from the BackgroundService. + ContextCompat.registerReceiver(this, mBroadcastReceiver, new IntentFilter(STOP_APP_ACTION), ContextCompat.RECEIVER_NOT_EXPORTED); + + // Register associated file opening result. + mOpenFilePermissionsResult = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (Build.VERSION.SDK_INT >= 30) { + if (Environment.isExternalStorageManager()) { + onFile(); + } + } else if (result.getResultCode() == RESULT_OK) { + onFile(); + } + } + ); + // Register file pick result. + mFilePickResult = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + int resultCode = result.getResultCode(); + Intent data = result.getData(); + if (resultCode == Activity.RESULT_OK) { + String path = ""; + if (data != null && data.getData() != null) { + Uri uri = data.getData(); + String name = "pick" + Utils.getFileExtension(uri, this); + File file = new File(getExternalCacheDir(), name); + try (InputStream is = getContentResolver().openInputStream(uri); + OutputStream os = new FileOutputStream(file)) { + byte[] buffer = new byte[1024]; + int length; + while (true) { + assert is != null; + if (!((length = is.read(buffer)) > 0)) break; + os.write(buffer, 0, length); + } + } catch (Exception e) { + Log.e("grim", e.toString()); + } + path = file.getPath(); + } + onFilePick(path); + } else { + onFilePick(""); + } + }); + + // Register file SAVE result (Storage Access Framework CREATE_DOCUMENT): + // copy the staged source file into the user-chosen document. + mFileSaveResult = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + String src = mPendingSavePath; + mPendingSavePath = null; + if (result.getResultCode() == Activity.RESULT_OK && src != null) { + Intent data = result.getData(); + if (data != null && data.getData() != null) { + Uri uri = data.getData(); + try (InputStream is = new FileInputStream(new File(src)); + OutputStream os = getContentResolver().openOutputStream(uri)) { + byte[] buffer = new byte[4096]; + int length; + while (is != null && (length = is.read(buffer)) > 0) { + os.write(buffer, 0, length); + } + if (os != null) os.flush(); + } catch (Exception e) { + Log.e("grim", e.toString()); + } + } + } + }); + + // Listener for display insets (cutouts) to pass values into native code. + View content = getWindow().getDecorView().findViewById(android.R.id.content); + ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> { + // Get display cutouts. + DisplayCutoutCompat dc = insets.getDisplayCutout(); + int cutoutTop = 0; + int cutoutRight = 0; + int cutoutBottom = 0; + int cutoutLeft = 0; + if (dc != null) { + cutoutTop = dc.getSafeInsetTop(); + cutoutRight = dc.getSafeInsetRight(); + cutoutBottom = dc.getSafeInsetBottom(); + cutoutLeft = dc.getSafeInsetLeft(); + } + + // Get display insets. + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + + // Pass values into native code. + int[] values = new int[]{0, 0, 0, 0}; + values[0] = Utils.pxToDp(Integer.max(cutoutTop, systemBars.top), this); + values[1] = Utils.pxToDp(Integer.max(cutoutRight, systemBars.right), this); + values[2] = Utils.pxToDp(Integer.max(cutoutBottom, systemBars.bottom), this); + values[3] = Utils.pxToDp(Integer.max(cutoutLeft, systemBars.left), this); + onDisplayInsets(values); + + return insets; + }); + + findViewById(android.R.id.content).post(() -> { + // Request notifications permissions if needed. + if (Build.VERSION.SDK_INT >= 33) { + String notificationsPermission = Manifest.permission.POST_NOTIFICATIONS; + if (checkSelfPermission(notificationsPermission) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[] { notificationsPermission }, NOTIFICATIONS_PERMISSION_CODE); + } else { + // Start notification service. + BackgroundService.start(this); + } + } else { + // Start notification service. + BackgroundService.start(this); + } + }); + + // Check if intent has data on launch. + if (savedInstanceState == null) { + onNewIntent(getIntent()); + } + } + + // Pass display insets into native code. + public native void onDisplayInsets(int[] cutouts); + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + String action = intent.getAction(); + // Check if file was open with the application. + if (action != null && action.equals(Intent.ACTION_VIEW)) { + Intent i = getIntent(); + i.setData(intent.getData()); + setIntent(i); + onFile(); + } + } + + // Callback when associated file was open. + private void onFile() { + Uri data = getIntent().getData(); + if (data == null) { + return; + } + if (Build.VERSION.SDK_INT >= 30) { + if (!Environment.isExternalStorageManager()) { + Intent i = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + mOpenFilePermissionsResult.launch(i); + return; + } + } + try { + ParcelFileDescriptor parcelFile = getContentResolver().openFileDescriptor(data, "r"); + assert parcelFile != null; + FileReader fileReader = new FileReader(parcelFile.getFileDescriptor()); + BufferedReader reader = new BufferedReader(fileReader); + String line; + StringBuilder buff = new StringBuilder(); + while ((line = reader.readLine()) != null) { + buff.append(line); + } + reader.close(); + fileReader.close(); + + // Provide file content into native code. + onData(buff.toString()); + } catch (Exception e) { + Log.e("grim", e.toString()); + } + } + + // Pass data into native code. + public native void onData(String data); + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + // Called on screen orientation change to restart camera. + if (mCameraProvider != null) { + stopCamera(); + startCamera(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] results) { + super.onRequestPermissionsResult(requestCode, permissions, results); + if (results.length != 0 && results[0] == PackageManager.PERMISSION_GRANTED) { + switch (requestCode) { + case NOTIFICATIONS_PERMISSION_CODE: { + BackgroundService.start(this); + return; + } + case CAMERA_PERMISSION_CODE: { + startCamera(); + } + } + } + } + + @Override + protected void onTextInputEventNative(long l, State state) { + super.onTextInputEventNative(l, state); + if (state.selectionEnd > state.composingRegionStart && state.composingRegionStart >= 0) { + String input = String.valueOf(state.text.charAt(state.composingRegionStart)); + if (input.contains("\n")) { + onEnterInput(); + } else { + onTextInput(input); + } + } + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + onBack(); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { + onClearInput(); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) { + onEnterInput(); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_0) { + onTextInput("0"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_1) { + onTextInput("1"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_2) { + onTextInput("2"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_3) { + onTextInput("3"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_4) { + onTextInput("4"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_5) { + onTextInput("5"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_6) { + onTextInput("6"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_7) { + onTextInput("7"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_8) { + onTextInput("8"); + return false; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_9) { + onTextInput("9"); + return false; + } + } else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) { + if (!event.getCharacters().isEmpty()) { + onTextInput(event.getCharacters()); + return false; + } + // Pass any other input values into native code. + } else if (event.getAction() == KeyEvent.ACTION_UP && + event.getKeyCode() != KeyEvent.KEYCODE_ENTER && + event.getKeyCode() != KeyEvent.KEYCODE_BACK) { + onTextInput(String.valueOf((char)event.getUnicodeChar())); + return false; + } + return super.dispatchKeyEvent(event); + } + + // Pass back navigation event into native code. + public native void onBack(); + + // Pass clear key event into native code. + public native void onClearInput(); + + // Pass enter key event into native code. + public native void onEnterInput(); + + // Pass last entered character from soft keyboard into native code. + public native void onTextInput(String character); + + // Called from native code to exit app. + public void exit() { + finishAndRemoveTask(); + } + + @Override + protected void onDestroy() { + unregisterReceiver(mBroadcastReceiver); + + // Only tear the process down when the activity is actually finishing. + // onDestroy also fires for configuration-change recreations (rotation, + // density, uiMode); killing the process there takes the whole app down + // right as Android is about to recreate the activity. + if (isFinishing()) { + BackgroundService.stop(this); + + // Kill process after 3 secs if app was terminated from recent apps to prevent app hang. + new Thread(() -> { + try { + onTermination(); + Thread.sleep(3000); + Process.killProcess(Process.myPid()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }).start(); + } + + super.onDestroy(); + } + + // Notify native code to stop activity (e.g. node) if app was terminated from recent apps. + public native void onTermination(); + + // Called from native code to set text into clipboard. + public void copyText(String data) { + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText(data, data); + clipboard.setPrimaryClip(clip); + } + + // Called from native code to get text from clipboard. + public String pasteText() { + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + ClipDescription desc = clipboard.getPrimaryClipDescription(); + ClipData data = clipboard.getPrimaryClip(); + String text = ""; + if (!(clipboard.hasPrimaryClip())) { + text = ""; + } else if (desc != null && (!(desc.hasMimeType(MIMETYPE_TEXT_PLAIN)) + && !(desc.hasMimeType(MIMETYPE_TEXT_HTML)))) { + text = ""; + } else if (data != null) { + ClipData.Item item = data.getItemAt(0); + text = item.getText().toString(); + } + return text; + } + + // Called from native code to start camera. + public void startCamera() { + String notificationsPermission = Manifest.permission.CAMERA; + if (checkSelfPermission(notificationsPermission) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[] { notificationsPermission }, CAMERA_PERMISSION_CODE); + } else { + if (mCameraProviderFuture == null) { + mCameraProviderFuture = ProcessCameraProvider.getInstance(this); + mCameraProviderFuture.addListener(() -> { + try { + mCameraProvider = mCameraProviderFuture.get(); + // Start camera. + openCamera(); + } catch (Exception e) { + View content = findViewById(android.R.id.content); + if (content != null) { + content.post(this::stopCamera); + } + } + }, ContextCompat.getMainExecutor(this)); + } else { + View content = findViewById(android.R.id.content); + if (content != null) { + content.post(this::openCamera); + } + } + } + } + + // Open camera after initialization or start after stop. + private void openCamera() { + // Set up the image analysis use case which will process frames in real time. + if (mCameraExecutor == null) { + mCameraExecutor = Executors.newSingleThreadExecutor(); + mImageAnalysis.setAnalyzer(mCameraExecutor, image -> { + // Convert image to JPEG. + byte[] data = Utils.convertCameraImage(image); + // Send image to native code. + onCameraImage(data, image.getImageInfo().getRotationDegrees()); + image.close(); + }); + } + + // Select back camera initially. + CameraSelector cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA; + if (!mUseBackCamera) { + cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA; + } + // Apply declared configs to CameraX using the same lifecycle owner + mCameraProvider.unbindAll(); + mCameraProvider.bindToLifecycle(this, cameraSelector, mImageAnalysis); + } + + // Called from native code to stop camera. + public void stopCamera() { + View content = findViewById(android.R.id.content); + if (content != null) { + content.post(() -> { + if (mCameraProvider != null) { + mCameraProvider.unbindAll(); + } + }); + } + } + + // Called from native code to get number of cameras. + public int camerasAmount() { + if (mCameraProvider == null) { + return 0; + } + return mCameraProvider.getAvailableCameraInfos().size(); + } + + // Called from native code to switch camera. + public void switchCamera() { + mUseBackCamera = !mUseBackCamera; + stopCamera(); + startCamera(); + } + + // Pass image from camera into native code. + public native void onCameraImage(byte[] buff, int rotation); + + // Called from native code to share data from provided path. + public void shareData(String path) { + File file = new File(path); + Uri uri = FileProvider.getUriForFile(this, "st.goblin.wallet.fileprovider", file); + Intent intent = new Intent(Intent.ACTION_SEND); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.setType("text/*"); + startActivity(Intent.createChooser(intent, "Share data")); + } + + // Called from native code to SAVE a staged file to a user-chosen location. + // Launches the Storage Access Framework create-document picker; the result + // handler copies the staged source file into the chosen document. + public void saveFile(String path, String name) { + mPendingSavePath = path; + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/octet-stream"); + intent.putExtra(Intent.EXTRA_TITLE, name); + try { + mFileSaveResult.launch(intent); + } catch (android.content.ActivityNotFoundException ex) { + Log.e("grim", ex.toString()); + mPendingSavePath = null; + } + } + + // Called from native code to share plain text (e.g. a payment link) via the + // system share sheet. + public void shareText(String text) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.putExtra(Intent.EXTRA_TEXT, text); + intent.setType("text/plain"); + startActivity(Intent.createChooser(intent, "Share")); + } + + // Called from native code to play a short "error" haptic (rejected payment). + public void vibrateError() { + Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator == null || !vibrator.hasVibrator()) { + return; + } + // Two short pulses read as "no" / rejected, distinct from a tap. + long[] pattern = new long[]{0, 40, 70, 40}; + if (Build.VERSION.SDK_INT >= 26) { + vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1)); + } else { + vibrator.vibrate(pattern, -1); + } + } + + // Called from native code to play a tiny "tick" haptic on a successful copy. + public void vibrateCopy() { + Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator == null || !vibrator.hasVibrator()) { + return; + } + // One short, light tick — a confirmation, not an alert. + if (Build.VERSION.SDK_INT >= 29) { + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)); + } else if (Build.VERSION.SDK_INT >= 26) { + vibrator.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + vibrator.vibrate(20); + } + } + + // Called from native code to set status-bar icon color to contrast the + // in-app theme. white = light icons for a dark background. The app draws + // edge-to-edge, so the OS status-bar background is the app's own content; + // the icons must follow the theme or they vanish (dark-on-dark). + public void setStatusBarWhiteIcons(boolean white) { + runOnUiThread(() -> { + androidx.core.view.WindowInsetsControllerCompat c = + androidx.core.view.WindowCompat.getInsetsController(getWindow(), + getWindow().getDecorView()); + // isAppearanceLightStatusBars == true means DARK icons. + c.setAppearanceLightStatusBars(!white); + }); + } + + // Called from native code to check if device is using dark theme. + public boolean useDarkTheme() { + int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return currentNightMode == Configuration.UI_MODE_NIGHT_YES; + } + + // Called from native code to pick the file. + public void pickFile() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("text/*"); + try { + mFilePickResult.launch(Intent.createChooser(intent, "Pick file")); + } catch (android.content.ActivityNotFoundException ex) { + onFilePick(""); + } + } + + // Pass picked file into native code. + public native void onFilePick(String path); +} \ No newline at end of file diff --git a/android/app/src/main/java/mw/gri/android/NotificationActionsReceiver.java b/android/app/src/main/java/mw/gri/android/NotificationActionsReceiver.java new file mode 100644 index 00000000..9487a014 --- /dev/null +++ b/android/app/src/main/java/mw/gri/android/NotificationActionsReceiver.java @@ -0,0 +1,28 @@ +package mw.gri.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import java.util.Objects; + +public class NotificationActionsReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent i) { + String a = i.getAction(); + if (Objects.equals(a, BackgroundService.ACTION_START_NODE)) { + startNode(); + } else if (Objects.equals(a, BackgroundService.ACTION_STOP_NODE)) { + stopNode(); + } else { + stopNodeToExit(); + } + } + + // Start integrated node. + native void startNode(); + // Stop integrated node. + native void stopNode(); + // Stop node and exit from the app. + native void stopNodeToExit(); +} diff --git a/android/app/src/main/java/mw/gri/android/Utils.java b/android/app/src/main/java/mw/gri/android/Utils.java new file mode 100644 index 00000000..92617c04 --- /dev/null +++ b/android/app/src/main/java/mw/gri/android/Utils.java @@ -0,0 +1,156 @@ +package mw.gri.android; + +import android.content.Context; +import android.graphics.ImageFormat; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.media.Image; +import android.net.Uri; +import android.webkit.MimeTypeMap; +import androidx.camera.core.ImageProxy; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.nio.ByteBuffer; + +public class Utils { + // Convert Pixels to DensityPixels + public static int pxToDp(int px, Context context) { + return (int) (px / context.getResources().getDisplayMetrics().density); + } + + /** Converts a YUV_420_888 image from CameraX API to a bitmap. */ + public static byte[] convertCameraImage(ImageProxy image) { + // Convert image to nv21 and get buffer. + ByteBuffer nv21Buffer = + yuv420ThreePlanesToNV21(image.getPlanes(), image.getWidth(), image.getHeight()); + nv21Buffer.rewind(); + byte[] nv21 = new byte[nv21Buffer.limit()]; + nv21Buffer.get(nv21); + // Convert to JPEG. + YuvImage yuvImage = new YuvImage(nv21, ImageFormat.NV21, image.getWidth(), image.getHeight(), null); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + yuvImage.compressToJpeg(new Rect(0, 0, yuvImage.getWidth(), yuvImage.getHeight()), 100, out); + return out.toByteArray(); + } + + /** + * Converts YUV_420_888 to NV21 bytebuffer. + * + *

The NV21 format consists of a single byte array containing the Y, U and V values. For an + * image of size S, the first S positions of the array contain all the Y values. The remaining + * positions contain interleaved V and U values. U and V are subsampled by a factor of 2 in both + * dimensions, so there are S/4 U values and S/4 V values. In summary, the NV21 array will contain + * S Y values followed by S/4 VU values: YYYYYYYYYYYYYY(...)YVUVUVUVU(...)VU + * + *

YUV_420_888 is a generic format that can describe any YUV image where U and V are subsampled + * by a factor of 2 in both dimensions. {@link Image#getPlanes} returns an array with the Y, U and + * V planes. The Y plane is guaranteed not to be interleaved, so we can just copy its values into + * the first part of the NV21 array. The U and V planes may already have the representation in the + * NV21 format. This happens if the planes share the same buffer, the V buffer is one position + * before the U buffer and the planes have a pixelStride of 2. If this is case, we can just copy + * them to the NV21 array. + */ + private static ByteBuffer yuv420ThreePlanesToNV21(ImageProxy.PlaneProxy[] yuv420888planes, int width, int height) { + int imageSize = width * height; + byte[] out = new byte[imageSize + 2 * (imageSize / 4)]; + + if (areUVPlanesNV21(yuv420888planes, width, height)) { + // Copy the Y values. + yuv420888planes[0].getBuffer().get(out, 0, imageSize); + + ByteBuffer uBuffer = yuv420888planes[1].getBuffer(); + ByteBuffer vBuffer = yuv420888planes[2].getBuffer(); + // Get the first V value from the V buffer, since the U buffer does not contain it. + vBuffer.get(out, imageSize, 1); + // Copy the first U value and the remaining VU values from the U buffer. + uBuffer.get(out, imageSize + 1, 2 * imageSize / 4 - 1); + } else { + // Fallback to copying the UV values one by one, which is slower but also works. + // Unpack Y. + unpackPlane(yuv420888planes[0], width, height, out, 0, 1); + // Unpack U. + unpackPlane(yuv420888planes[1], width, height, out, imageSize + 1, 2); + // Unpack V. + unpackPlane(yuv420888planes[2], width, height, out, imageSize, 2); + } + + return ByteBuffer.wrap(out); + } + + /** Checks if the UV plane buffers of a YUV_420_888 image are in the NV21 format. */ + private static boolean areUVPlanesNV21(ImageProxy.PlaneProxy[] planes, int width, int height) { + int imageSize = width * height; + + ByteBuffer uBuffer = planes[1].getBuffer(); + ByteBuffer vBuffer = planes[2].getBuffer(); + + // Backup buffer properties. + int vBufferPosition = vBuffer.position(); + int uBufferLimit = uBuffer.limit(); + + // Advance the V buffer by 1 byte, since the U buffer will not contain the first V value. + vBuffer.position(vBufferPosition + 1); + // Chop off the last byte of the U buffer, since the V buffer will not contain the last U value. + uBuffer.limit(uBufferLimit - 1); + + // Check that the buffers are equal and have the expected number of elements. + boolean areNV21 = + (vBuffer.remaining() == (2 * imageSize / 4 - 2)) && (vBuffer.compareTo(uBuffer) == 0); + + // Restore buffers to their initial state. + vBuffer.position(vBufferPosition); + uBuffer.limit(uBufferLimit); + + return areNV21; + } + + /** + * Unpack an image plane into a byte array. + * + *

The input plane data will be copied in 'out', starting at 'offset' and every pixel will be + * spaced by 'pixelStride'. Note that there is no row padding on the output. + */ + private static void unpackPlane( + ImageProxy.PlaneProxy plane, int width, int height, byte[] out, int offset, int pixelStride) { + ByteBuffer buffer = plane.getBuffer(); + buffer.rewind(); + + // Compute the size of the current plane. + // We assume that it has the aspect ratio as the original image. + int numRow = (buffer.limit() + plane.getRowStride() - 1) / plane.getRowStride(); + if (numRow == 0) { + return; + } + int scaleFactor = height / numRow; + int numCol = width / scaleFactor; + + // Extract the data in the output buffer. + int outputPos = offset; + int rowStart = 0; + for (int row = 0; row < numRow; row++) { + int inputPos = rowStart; + for (int col = 0; col < numCol; col++) { + out[outputPos] = buffer.get(inputPos); + outputPos += pixelStride; + inputPos += plane.getPixelStride(); + } + rowStart += plane.getRowStride(); + } + } + + public static boolean deleteDirectoryContent(File directoryToBeDeleted, boolean deleteDirectory) { + File[] allContents = directoryToBeDeleted.listFiles(); + if (allContents != null) { + for (File file : allContents) { + deleteDirectoryContent(file, true); + } + } + return directoryToBeDeleted.delete(); + } + + public static String getFileExtension(Uri uri, Context context) { + String fileType = context.getContentResolver().getType(uri); + return MimeTypeMap.getSingleton().getExtensionFromMimeType(fileType); + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_close.xml b/android/app/src/main/res/drawable-anydpi/ic_close.xml new file mode 100644 index 00000000..b333f539 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_close.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/ic_start.xml b/android/app/src/main/res/drawable-anydpi/ic_start.xml new file mode 100644 index 00000000..a00c0ed1 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_start.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/ic_stop.xml b/android/app/src/main/res/drawable-anydpi/ic_stop.xml new file mode 100644 index 00000000..957c847e --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_stop.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-hdpi/ic_close.png b/android/app/src/main/res/drawable-hdpi/ic_close.png new file mode 100644 index 00000000..136ac44e Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_close.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_start.png b/android/app/src/main/res/drawable-hdpi/ic_start.png new file mode 100644 index 00000000..e61e66af Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_start.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_name.png b/android/app/src/main/res/drawable-hdpi/ic_stat_name.png new file mode 100644 index 00000000..c4385b47 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_stat_name.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_stop.png b/android/app/src/main/res/drawable-hdpi/ic_stop.png new file mode 100644 index 00000000..44c823e7 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_stop.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_close.png b/android/app/src/main/res/drawable-mdpi/ic_close.png new file mode 100644 index 00000000..a6551fb9 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_close.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_start.png b/android/app/src/main/res/drawable-mdpi/ic_start.png new file mode 100644 index 00000000..9611b5fa Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_start.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_stat_name.png b/android/app/src/main/res/drawable-mdpi/ic_stat_name.png new file mode 100644 index 00000000..39c3c488 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_stat_name.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_stop.png b/android/app/src/main/res/drawable-mdpi/ic_stop.png new file mode 100644 index 00000000..f7444508 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_stop.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_close.png b/android/app/src/main/res/drawable-xhdpi/ic_close.png new file mode 100644 index 00000000..433762c6 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_close.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_start.png b/android/app/src/main/res/drawable-xhdpi/ic_start.png new file mode 100644 index 00000000..6cf694b7 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_start.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png b/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png new file mode 100644 index 00000000..c46f7223 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stop.png b/android/app/src/main/res/drawable-xhdpi/ic_stop.png new file mode 100644 index 00000000..b1de0c55 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_stop.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_close.png b/android/app/src/main/res/drawable-xxhdpi/ic_close.png new file mode 100644 index 00000000..a403ca3f Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_close.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_start.png b/android/app/src/main/res/drawable-xxhdpi/ic_start.png new file mode 100644 index 00000000..57227f5c Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_start.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png b/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png new file mode 100644 index 00000000..869b711c Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stop.png b/android/app/src/main/res/drawable-xxhdpi/ic_stop.png new file mode 100644 index 00000000..56a8d470 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_stop.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png new file mode 100644 index 00000000..96df0c20 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..97aee232 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ef80a1ca Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..97aee232 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..28aa6369 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..306758f3 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..28aa6369 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..533fa7fa Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..a0361289 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..533fa7fa Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..f6d6c2db Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..b9efa04a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..f6d6c2db Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..15cecba2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..27530754 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..15cecba2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f900d41b --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #FFFFFFFF + #FFFEF102 + #FF000000 + \ No newline at end of file diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..3ae02f52 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFD60A + \ No newline at end of file diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..b8205247 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/paths.xml b/android/app/src/main/res/xml/paths.xml new file mode 100644 index 00000000..f1b85531 --- /dev/null +++ b/android/app/src/main/res/xml/paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..56ef74f1 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,5 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id 'com.android.application' version '8.10.0' apply false + id 'com.android.library' version '8.10.0' apply false +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..fad67c4f --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..36be38b0 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon May 02 15:39:12 BST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://code.gri.mw/DEV/gradle/releases/download/v8.11.1/gradle-8.11.1-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 00000000..4f906e0c --- /dev/null +++ b/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 00000000..8cf96c5f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + // Private mirror only when its coordinates are supplied (-PmavenHost=… as in upstream CI); + // everyone else resolves plugins from the public repositories. + def mavenHost = providers.gradleProperty("mavenHost") + if (mavenHost.present) { + def mavenUser = providers.gradleProperty("mavenUser").get() + def mavenPassword = providers.gradleProperty("mavenPassword").get() + ["gradle-plugin-portal", "google-maven", "maven-central"].each { repo -> + maven { + credentials { + username mavenUser + password mavenPassword + } + url "${mavenHost.get()}/repository/${repo}/" + allowInsecureProtocol = true + } + } + } else { + gradlePluginPortal() + google() + mavenCentral() + } + } +} +include ':app' diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..3ec3e0fb --- /dev/null +++ b/build.rs @@ -0,0 +1,88 @@ +use std::env; +use std::path::PathBuf; +use std::process::Command; + +/// The GRIM commit Goblin forked from; builds count commits on top of it. +const GOBLIN_FORK_BASE: &str = "b51a46b"; + +fn main() { + built::write_built_file().expect("Failed to acquire build-time information"); + + // Goblin versioning is build-based: Build N = commits since the fork. + // An explicit GOBLIN_BUILD env wins (CI builds from the public single-commit + // squash where the fork base isn't an ancestor, so the git count can't run); + // otherwise count commits since the fork; "dev" only as a last resort. + let build = env::var("GOBLIN_BUILD") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| { + Command::new("git") + .args([ + "rev-list", + "--count", + &format!("{}..HEAD", GOBLIN_FORK_BASE), + ]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(|| "dev".to_string()); + println!("cargo:rustc-env=GOBLIN_BUILD={}", build); + // .git/HEAD only changes on branch switches; the reflog is appended on + // every commit, so the build number stays current. + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/logs/HEAD"); + println!("cargo:rerun-if-env-changed=GOBLIN_BUILD"); + + // Setting up git hooks in the project: rustfmt and so on. + let git_hooks = format!( + "git config core.hooksPath {}", + PathBuf::from("./.hooks").to_str().unwrap() + ); + + if cfg!(target_os = "windows") { + Command::new("cmd") + .args(&["/C", &git_hooks]) + .output() + .expect("failed to execute git config for hooks"); + } else { + Command::new("sh") + .args(&["-c", &git_hooks]) + .output() + .expect("failed to execute git config for hooks"); + } + + // Goblin links the Nym mixnet SDK in-process (see src/nym/) — no sidecar + // subprocess, no bundled/embedded helper binary, and no Tor/webtunnel. There + // is nothing transport-related to build or embed here. + + // Embed the Goblin icon into goblin.exe so Explorer, the taskbar and Alt-Tab + // show it even for the bare exe (the .msi shortcuts already carry it). No-op + // on every non-Windows platform. + embed_windows_icon(); +} + +/// Embed `wix/Product.ico` (the yellow Goblin icon) as goblin.exe's application +/// icon resource. Gated to Windows hosts — that's where the `winresource` +/// build-dependency is compiled and where the MSVC resource compiler (`rc.exe`, +/// shipped on the windows-latest runner) is available; our Windows builds are +/// always native MSVC, so host == target == windows. +#[cfg(windows)] +fn embed_windows_icon() { + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let mut res = winresource::WindowsResource::new(); + res.set_icon("wix/Product.ico"); + if let Err(e) = res.compile() { + // Don't fail the build over the icon — just flag it. + println!("cargo:warning=winresource icon embed failed: {e}"); + } +} + +#[cfg(not(windows))] +fn embed_windows_icon() {} diff --git a/docs/TRANSACTIONS.md b/docs/TRANSACTIONS.md new file mode 100644 index 00000000..847d3b29 --- /dev/null +++ b/docs/TRANSACTIONS.md @@ -0,0 +1,377 @@ +# Goblin transactions — how a payment works, end to end + +This document explains the full lifecycle of a Goblin payment: how money moves, +every status it passes through, and the small guarantees that keep it safe. It is +written against the code in `src/nostr/` and `src/wallet/` — function names and +files are cited (line numbers drift, so they aren't). + +--- + +## 1. The big picture: two layers + +A Goblin payment is **a Grin transaction wrapped in a private nostr message**. + +1. **Grin layer (the money).** Grin/Mimblewimble transactions are *interactive*: + the sender and recipient exchange a "slate" that passes through states until + it's finalized and posted on-chain. There are no addresses and no amounts on + the chain. Goblin reuses GRIM's full Grin node + wallet engine for this. + +2. **Nostr layer (the delivery).** Instead of making you hand slate files back + and forth, Goblin delivers each slate as an **end-to-end-encrypted nostr + direct message**, routed through the **Nym mixnet**. You pay a `username` or + `npub`; the recipient's wallet applies the slate automatically. + +The slate is the payload; nostr is the transport. Everything below is about how +those two layers move together and what state is tracked at each step. + +### Slate states (Grin layer) + +Interactive Grin slates pass through numbered states. Goblin uses two flows: + +| Flow | States | Who builds what | +| --- | --- | --- | +| **Standard** (sender pushes money) | `Standard1` → `Standard2` → `Standard3` | Sender builds S1 (locks their outputs), recipient replies S2, sender finalizes S3 and posts | +| **Invoice** (recipient pulls money) | `Invoice1` → `Invoice2` → `Invoice3` | Requester builds I1 (the ask), payer replies I2 (pays), requester finalizes I3 and posts | + +### Status + direction (Goblin's nostr metadata) + +For each payment Goblin stores a `TxNostrMeta` (`src/nostr/types.rs`) keyed by +slate id, with a **direction** and a **status**: + +`NostrTxDirection`: +- `Sent` — we pushed funds (we created S1). +- `Received` — we were paid (we replied S2). +- `RequestedByUs` — we issued an invoice (we created I1). +- `RequestedOfUs` — someone invoiced us and we paid it (we replied I2). + +`NostrSendStatus`: +- `Created` — slate built locally, DM not dispatched yet (durable checkpoint). +- `AwaitingS2` — S1 sent, waiting for the recipient's S2 reply. +- `ReceivedNoReply` — we processed an incoming S1 (or I1) and built our reply, but haven't dispatched it yet (crash-recovery point). +- `RepliedS2` — our S2 reply was dispatched (we received a payment). +- `AwaitingI2` — our I1 invoice was sent, waiting for the payer's I2. +- `PaidAwaitingFinalize` — we paid an invoice (sent I2); the requester finalizes. +- `Finalized` — slate finalized and posted on-chain. +- `SendFailed` — DM dispatch failed; eligible for retry. +- `Cancelled` — cancelled locally (manual cancel or 24h expiry). + +`Finalized` and `Cancelled` are **terminal**. + +--- + +## 2. Identity & addressing + +Your nostr identity is a key that is **deliberately not derived from your wallet +seed** (`src/nostr/identity.rs`) — so you can rotate it any time to stay +unlinkable without ever touching your funds. It's stored encrypted at rest +(NIP-49 ncryptsec under your wallet password). + +You can optionally claim a human-readable **`username`** from a **name authority** +(a NIP-05 server). The authority is configurable (Settings → Identity → Name +authority; `NostrConfig::{nip05_server, home_domain, set_nip05_server}`), which is +what makes Goblin **federated**: a user on `bob@otherinstance.com` can pay +`alice@goblin.st`, because a full `name@domain` always resolves against that +domain's `/.well-known/nostr.json`. Bare names (`alice`) resolve against *your* +configured home authority. + +Display rules (`data::display_name`, no `@` ever shown): +- A local **petname** wins. +- A verified name on **your home authority** shows bare (`alice`) + a check. +- A verified name on a **foreign authority** shows `alice · domain` + a check, so + it can never masquerade as a home name. +- Otherwise: a short npub. + +Names are kept fresh: see [§11 Name freshness](#11-contacts--name-freshness). + +--- + +## 3. Transport: NIP-17 gift wraps over Nym + +A payment DM is built and sent by `send_payment_dm`; control messages (voids) by +`send_control_dm` (both in `src/nostr/client.rs`). The message structure +(`src/nostr/protocol.rs`): + +- The **payload** is the raw Grin slatepack armor (`BEGINSLATEPACK… ENDSLATEPACK`) + inside a kind-14 rumor, prefixed with a human preamble (`[Goblin] GRIN payment + message — open in Goblin …`) so a non-Goblin nostr client shows something sane. +- **Tags:** a `["goblin","1"]` protocol marker always; an optional `["subject", …]` + carrying the user's note (sanitized); control DMs carry + `["goblin-action","void", slate_id]` and **no** slatepack. +- The rumor is sealed and wrapped as a **kind-1059 gift wrap** (NIP-59 + NIP-44 + encryption) via nostr-sdk's `send_private_msg_to`. Relays only ever see + ciphertext — never the amount, sender, or recipient. + +**Where it's delivered:** the recipient's **kind-10050 DM-relay list** +(`fetch_dm_relays`), with our own default relays as fallback, plus any relay +hints carried by a pasted `nprofile`. Default relays: `relay.goblin.st`, +`relay.damus.io`, `nos.lol` (`src/nostr/relays.rs`), capped at `MAX_DM_RELAYS`. + +**How relays are reached:** every relay connection runs through the in-process +**Nym mixnet** SOCKS5 proxy (`NymWebSocketTransport`; `run_service` waits for the +proxy to be ready before dialing). The mixnet hides who-talks-to-whom at the +network layer. The Grin *node* connection (block sync + broadcasting the final tx) +is direct clearnet — it's public chain data, the same for everyone, not tied to +your identity. + +The UI tracks an outgoing attempt via a coarse **send phase** +(`client::send_phase`): `IDLE / WORKING / SENT / FAILED / REQUEST_BLOCKED`, with a +human-readable failure reason on `FAILED`. + +--- + +## 4. Flow A — Pay by username/npub (Standard, we send) + +Dispatched as `WalletTask::NostrSend(amount, npub, note, relay_hints)`; handled in +`wallet.rs`. + +1. `w.send(amount)` builds the **S1** slate and **locks our outputs** (the funds + are reserved but not yet spent). +2. **Save meta `Created`** *before* any network call — this is the durable point + a crash recovers from. +3. `send_payment_dm` delivers S1 → **`AwaitingS2`** (storing the gift-wrap event + id). On dispatch failure → **`SendFailed`** (retryable). On success the contact + is created/refreshed (so people you pay appear in Suggested) and a background + NIP-05 lookup resolves their name. +4. The recipient replies S2 (Flow B). When their S2 gift wrap arrives, the ingest + guard routes it to `nostr_finalize_post`, which finalizes **S3** and posts it + on-chain → **`Finalized`**. + +``` +Created ──(S1 sent)──▶ AwaitingS2 ──(their S2 arrives)──▶ Finalized + └──(dispatch fails)──▶ SendFailed ──(retry)──▶ AwaitingS2 + └──(manual cancel / 24h expiry)──▶ Cancelled (outputs unlocked) +``` + +--- + +## 5. Flow B — Receiving (Standard, we're paid) + +Our service subscribes to kind-1059 gift wraps addressed to us +(`run_service`). When an **S1** arrives, `handle_wrap` runs the ingest pipeline +(§7) and `decide()` classifies it by the **accept policy**: + +- `Everyone` → **AutoReceive** (auto-reply S2). +- `Contacts` → AutoReceive if the sender is a known contact, else **SurfaceIncoming** (an approval card). +- `Ask` → always SurfaceIncoming. + +**AutoReceive:** `nostr_receive` builds the **S2** reply; we save meta +`Received` / **`ReceivedNoReply`**, mark the message processed, then dispatch S2 → +**`RepliedS2`**. If the S2 dispatch fails we stay at `ReceivedNoReply` and resend +on the next start (§9). The sender then finalizes S3 (Flow A step 4). + +``` +(incoming S1) ──▶ ReceivedNoReply ──(S2 dispatched)──▶ RepliedS2 + └──(dispatch fails)──▶ stays ReceivedNoReply → resent on restart +``` + +**SurfaceIncoming** instead stores a `PaymentRequest` (status `Pending`) for the +user to approve or decline — see Flow D. + +--- + +## 6. Flow C — Request money (Invoice) + +**We request** — `WalletTask::NostrRequest(amount, npub, note, …)`: + +1. First we check the recipient hasn't opted out: `accepts_requests` reads their + kind-0 `goblin_accepts_requests` field; an explicit `false` → phase + `REQUEST_BLOCKED` and we stop (fail-open: unknown/unreachable = allowed). +2. `issue_invoice(amount)` builds **I1** (no outputs locked — it's just an ask). +3. Save meta `RequestedByUs / Created`, dispatch I1 → **`AwaitingI2`**. +4. When the payer's **I2** arrives, the ingest guard finalizes **I3** and posts → + **`Finalized`**. + +**They approve & pay** (the other side of the same flow) is Flow D. + +``` +Created ──(I1 sent)──▶ AwaitingI2 ──(their I2 arrives)──▶ Finalized + └──(SendFailed → retry) └──(cancel / expiry)──▶ Cancelled +``` + +--- + +## 7. Flow D — Approving an incoming request (we pay an invoice) + +Someone's **I1** is *always* surfaced as a `PaymentRequest`, **never auto-paid** +(a hard security invariant). It shows in the Requests list. The user can: + +- **Approve** → `WalletTask::NostrPayRequest(rumor_id)`: re-parse the stored + slatepack (must still be I1), `nostr_pay` builds **I2** (this is where *we* pay), + save meta `RequestedOfUs / ReceivedNoReply`, dispatch I2 → **`PaidAwaitingFinalize`**, + mark the request `Approved`. The requester then finalizes I3. + A "Paying…" spinner shows while this runs; the card clears on success. +- **Decline** → `WalletTask::NostrDeclineRequest(rumor_id)`: mark `Declined` and + send a **void** control DM so the requester's side clears too. + +A surfaced incoming *Standard* S1 (from SurfaceIncoming) is approved the same way, +but routes through `nostr_receive` (Flow B) rather than `nostr_pay`. + +`RequestStatus`: `Pending → Approved | Declined | Expired | Cancelled` +(`Cancelled` = the requester withdrew it via a void). + +--- + +## 8. The ingest guard — `decide()` + +Every incoming gift wrap is judged by `decide()` (`src/nostr/ingest.rs`), a +**positive allow-list**: anything not explicitly accepted is `Drop`ped. This is +the security core. Summary: + +| Incoming state | Condition | Decision | +| --- | --- | --- | +| `Standard1` | amount 0, or slate already known | **Drop** | +| `Standard1` | new, policy `Everyone` (or `Contacts` + known) | **AutoReceive** | +| `Standard1` | new, policy `Contacts` + unknown, or `Ask` | **SurfaceIncoming** | +| `Standard2` | matches our pending `Sent` tx (status `AwaitingS2/Created/SendFailed`) **and** sender == stored counterparty **and** the tx exists | **FinalizePost** | +| `Standard2` | sender mismatch, or status `Cancelled`/`Finalized`, or no meta | **Drop** | +| `Invoice1` | amount 0, already known, or incoming-requests disabled | **Drop** | +| `Invoice1` | otherwise | **SurfaceRequest** (never auto-pay) | +| `Invoice2` | matches our pending `RequestedByUs` tx + sender match | **FinalizePost** | +| `Invoice3` / unknown | — | **Drop** | + +Key consequences: +- A **late S2 on a cancelled send** falls through to `Drop` — so cancelling is + safe even if the recipient's reply is still in flight (the cancel marks the meta + `Cancelled` *first*, and `decide()` then drops the S2). +- Finalize only happens for a slate we are actually waiting on, from the exact key + we sent to. +- Invoices are never auto-paid. + +--- + +## 9. Cancel & reclaim + +A stuck outgoing send (recipient never replied) locks your outputs. You can +reclaim them manually from the receipt's **"Cancel payment"** button, or the 24h +auto-expiry does it for you (§10). + +`WalletTask::NostrCancelSend(slate_id)` (`wallet.rs`): +1. Take the `cancel_finalize_lock` — this **serializes against a concurrent S2 + finalize** so the two can't both win (cancel-and-post would be a double action). +2. **Re-check live state under the lock.** If the tx is already `Finalized`, or + confirmed/posted on-chain → do nothing and return `CancelOutcome::AlreadyCompleted` + ("This payment already went through and can't be cancelled"). If already + `Cancelled` → idempotent success. +3. Otherwise mark the meta **`Cancelled` first**, then `w.cancel(tx_id)` to unlock + the Grin outputs, then best-effort send a **void** control DM to the recipient + (they're likely offline). → `CancelOutcome::Cancelled` ("your funds are + available again"). + +**Receipt button visibility** (`cancelable_send` gate): shown only while the send +is still unanswered — direction `Sent`, status in `{Created, AwaitingS2, +SendFailed}`, **not** confirmed, **not** already cancelled, and either it never +reached a relay (`SendFailed`, shown immediately) or the grace window +(`cancel_grace_secs`, default 600s) has passed. The instant the recipient accepts +(status leaves that set) the button disappears. + +**Recipient side / void ordering:** a void control message marks the slate so that +if the recipient's wallet later (or earlier) sees the S1, it's dropped — including +the **void-before-S1** race, where the void arrives first and is recorded as +`void:{slate_id}:{sender}` so the subsequent S1 is dropped. + +There are sibling tasks for the other directions: `NostrCancelOutgoing` (withdraw +an invoice we issued) and `NostrDeclineRequest` (decline an incoming request) — +both send a void and mark the local record. + +--- + +## 10. Auto-expiry (24h) + +`expire_stale` (`src/nostr/client.rs`) runs from the sync loop. Any non-terminal +meta older than `expiry_secs` (default 24h) is expired: + +- If it **locked our outputs** (`expiry_locks_outputs`: a `Sent` send in + `Created/AwaitingS2/SendFailed`, or a `RequestedOfUs` invoice we paid in + `PaidAwaitingFinalize`) → cancel the Grin tx to unlock, and mark meta `Cancelled`. +- If it locked nothing of ours (incoming payments, invoices we issued) → just + annotate `Cancelled`. +- Pending incoming `PaymentRequest`s flip to `Expired`. + +This is the same unlock path as manual cancel; the manual button just lets you act +before the 24h. + +--- + +## 11. Crash recovery (`reconcile`) + +On service start, `reconcile` (`client.rs`) re-dispatches any pending outgoing +message within a 7-day window, by `(direction, status)`: + +| Direction · status | Slate | Action | +| --- | --- | --- | +| `Sent` · `Created`/`SendFailed` | Standard1 | resend S1 → `AwaitingS2` | +| `RequestedByUs` · `Created`/`SendFailed` | Invoice1 | resend I1 → `AwaitingI2` | +| `Received` · `ReceivedNoReply` | Standard2 | resend S2 → `RepliedS2` | +| `RequestedOfUs` · `ReceivedNoReply` | Invoice2 | resend I2 → `PaidAwaitingFinalize` | + +Because the slatepack text is persisted and the meta is written *before* every +dispatch, a crash at any point is recoverable: re-sending an already-delivered +message is harmless (the peer dedups it; see §12). + +--- + +## 12. Confirmations (X / N) + +A posted Grin tx matures over `min_confirmations` blocks (default 10) before it's +spendable. Grin marks a tx `confirmed` at the **first** block, but Goblin's +receipt counts toward the spendable threshold so the number actually moves +(`data::receipt_detail`): + +- broadcast, no block yet → `0 / N` +- on-chain, immature → `count / N` where `count = tip − inclusion_height + 1` +- `count ≥ N` → matured (shown as complete; the receipt's network-fee row is shown + only for outgoing payments — a recipient pays no fee). + +--- + +## 13. Reliability primitives + +- **Dedup / processed markers** (`store::{is_processed, mark_processed, + prune_processed}`): every wrap is recorded at three levels — the gift-wrap event + id, the inner rumor id, and `slate:{id}:{state}` — so a replayed or re-sent + message is processed exactly once. Markers TTL out after 30 days + (pruned on start + hourly). +- **Rate limiting** (`allow_sender`): per-sender sliding window — 30 events/hour + for known contacts, 10/hour for unknowns — plus a global decrypt ceiling + (~120 NIP-44 unwraps/min) to bound CPU/battery against fresh-keypair spam. A + message dropped purely for the *global* ceiling isn't marked processed, so it can + be retried later. +- **Seal integrity:** the gift-wrap seal signer must equal the inner rumor author, + and self-addressed messages are dropped. +- **The cancel/finalize lock** (§9) prevents a cancel and a finalize from both + succeeding on the same slate. + +--- + +## 14. Name freshness (contacts) + +Cached `@usernames` are re-validated against the name authority on a periodic +sweep (`NAME_REVERIFY_INTERVAL_SECS`, ~78s, capped per tick), and once at app open +(persisted `last_name_sweep_at`, gated to the interval). +`nip05::check` returns `Verified / Mismatch / Unreachable`: a name is only +**cleared** (falls back to the npub) on a definitive `Mismatch` (the server says +it's gone or now maps to a different key) — never on a transient network failure. +This catches released or reassigned names and stops a freed name from +impersonating someone. A user-set petname is never touched. + +--- + +## 15. File map + +| Concern | File | +| --- | --- | +| Status / direction / meta types | `src/nostr/types.rs` | +| Gift-wrap + control message build/parse | `src/nostr/protocol.rs` | +| Service loop, send/receive/finalize, expiry, reconcile, name sweep | `src/nostr/client.rs` | +| Ingest allow-list | `src/nostr/ingest.rs` | +| Wallet task handlers (NostrSend / Request / PayRequest / CancelSend / finalize) | `src/wallet/wallet.rs` | +| Task definitions | `src/wallet/types.rs` | +| Metadata + dedup + contacts store | `src/nostr/store.rs` | +| NIP-05 resolve / verify / register, name authority | `src/nostr/nip05.rs` | +| Identity (key, NIP-49 backup) | `src/nostr/identity.rs` | +| Receipt / activity / confirmations / display name | `src/gui/views/goblin/data.rs` | +| Relay defaults + name-authority defaults | `src/nostr/relays.rs` | + +--- + +🤖 Documentation written with AI pair-programming assistance (Claude). diff --git a/fonts/GEIST-OFL.txt b/fonts/GEIST-OFL.txt new file mode 100644 index 00000000..04e95fc5 --- /dev/null +++ b/fonts/GEIST-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/fonts/Geist-Bold.ttf b/fonts/Geist-Bold.ttf new file mode 100644 index 00000000..863b2868 Binary files /dev/null and b/fonts/Geist-Bold.ttf differ diff --git a/fonts/Geist-Medium.ttf b/fonts/Geist-Medium.ttf new file mode 100644 index 00000000..96cb22f8 Binary files /dev/null and b/fonts/Geist-Medium.ttf differ diff --git a/fonts/Geist-Regular.ttf b/fonts/Geist-Regular.ttf new file mode 100644 index 00000000..4da1b3a6 Binary files /dev/null and b/fonts/Geist-Regular.ttf differ diff --git a/fonts/Geist-SemiBold.ttf b/fonts/Geist-SemiBold.ttf new file mode 100644 index 00000000..b2b0618f Binary files /dev/null and b/fonts/Geist-SemiBold.ttf differ diff --git a/fonts/GeistMono-Regular.ttf b/fonts/GeistMono-Regular.ttf new file mode 100644 index 00000000..50c9d5a6 Binary files /dev/null and b/fonts/GeistMono-Regular.ttf differ diff --git a/fonts/GeistMono-SemiBold.ttf b/fonts/GeistMono-SemiBold.ttf new file mode 100644 index 00000000..1b21724d Binary files /dev/null and b/fonts/GeistMono-SemiBold.ttf differ diff --git a/fonts/NotoSansJpTsu.otf b/fonts/NotoSansJpTsu.otf new file mode 100644 index 00000000..6be74cfe Binary files /dev/null and b/fonts/NotoSansJpTsu.otf differ diff --git a/fonts/noto_sc_reg.otf b/fonts/noto_sc_reg.otf new file mode 100644 index 00000000..25622c93 Binary files /dev/null and b/fonts/noto_sc_reg.otf differ diff --git a/fonts/phosphor.ttf b/fonts/phosphor.ttf new file mode 100644 index 00000000..1f9fa7fa Binary files /dev/null and b/fonts/phosphor.ttf differ diff --git a/img/goblin-icon.png b/img/goblin-icon.png new file mode 100644 index 00000000..f5b1440c Binary files /dev/null and b/img/goblin-icon.png differ diff --git a/img/goblin-logo2-48.png b/img/goblin-logo2-48.png new file mode 100644 index 00000000..e01d63a2 Binary files /dev/null and b/img/goblin-logo2-48.png differ diff --git a/img/goblin-logo2.svg b/img/goblin-logo2.svg new file mode 100644 index 00000000..0fd97514 --- /dev/null +++ b/img/goblin-logo2.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/img/goblin-mark-black.svg b/img/goblin-mark-black.svg new file mode 100644 index 00000000..c0140909 --- /dev/null +++ b/img/goblin-mark-black.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/img/goblin-mark-white.svg b/img/goblin-mark-white.svg new file mode 100644 index 00000000..0fd97514 --- /dev/null +++ b/img/goblin-mark-white.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/img/icon.png b/img/icon.png new file mode 100644 index 00000000..f8b32cf5 Binary files /dev/null and b/img/icon.png differ diff --git a/linux/Goblin.AppDir/.DirIcon b/linux/Goblin.AppDir/.DirIcon new file mode 120000 index 00000000..a25debeb --- /dev/null +++ b/linux/Goblin.AppDir/.DirIcon @@ -0,0 +1 @@ +goblin.png \ No newline at end of file diff --git a/linux/Goblin.AppDir/goblin.desktop b/linux/Goblin.AppDir/goblin.desktop new file mode 100644 index 00000000..66649a25 --- /dev/null +++ b/linux/Goblin.AppDir/goblin.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Goblin +Exec=goblin +Icon=goblin +Type=Application +Categories=Finance +MimeType=application/x-slatepack;text/plain; \ No newline at end of file diff --git a/linux/Goblin.AppDir/goblin.png b/linux/Goblin.AppDir/goblin.png new file mode 100644 index 00000000..f8b32cf5 Binary files /dev/null and b/linux/Goblin.AppDir/goblin.png differ diff --git a/linux/build_release.sh b/linux/build_release.sh new file mode 100755 index 00000000..bf9437c8 --- /dev/null +++ b/linux/build_release.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# +# Build a portable, single-file Goblin AppImage. +# +# Usage: linux/build_release.sh [platform] +# platform: 'x86_64' (default) or 'arm' +# +# Goblin links the Nym SDK IN-PROCESS (src/nym/), so the AppImage is one +# self-contained binary with no sidecar to embed or ship beside it. + +set -euo pipefail + +platform="${1:-x86_64}" +case "${platform}" in + x86_64) arch="x86_64-unknown-linux-gnu" ;; + arm) arch="aarch64-unknown-linux-gnu" ;; + *) echo "Usage: build_release.sh [platform] (platform: 'x86_64' | 'arm')" >&2; exit 1 ;; +esac + +# Repo root (this script lives in linux/). +BASEDIR=$(cd "$(dirname "$0")" && pwd) +cd "${BASEDIR}/.." + +# Prefer the GRIM-canonical toolchains (zig + appimagetool from code.gri.mw/DEV); +# scripts/toolchain.sh fetches them and writes this env. Falls back to system +# installs when it's absent. +[ -f .toolchains/env.sh ] && source .toolchains/env.sh + +rustup target add "${arch}" +command -v cargo-zigbuild >/dev/null || cargo install cargo-zigbuild + +# Portable cross-build to glibc 2.17. Three zig-specific fixes: +# - CRoaring's AVX512 path won't compile under zig's clang (evex512 error). +# - OpenSSL is vendored in Cargo.toml, so no system libssl is needed. +# - v4l2-sys (camera/QR backend) runs bindgen over linux/videodev2.h, a kernel +# UAPI header missing from zig 0.12.1's glibc-2.17 sysroot; point bindgen at +# the host's kernel headers. This only reads struct layouts — the actual libc +# linkage stays glibc-2.17, so portability is unaffected. +export CFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0" +export CXXFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0" +export BINDGEN_EXTRA_CLANG_ARGS="${BINDGEN_EXTRA_CLANG_ARGS:-} -I/usr/include" +cargo zigbuild --release --target "${arch}.2.17" + +# Assemble the AppDir: AppRun IS the goblin binary (Nym SDK linked in), plus the +# icon + desktop entry. Nothing else. +appdir="linux/Goblin.AppDir" +cp "target/${arch}/release/goblin" "${appdir}/AppRun" +chmod +x "${appdir}/AppRun" + +out="target/${arch}/release/Goblin-${platform}.AppImage" +rm -f "target/${arch}/release/"*.AppImage +# Use the DEV appimagetool + type2 runtime when fetched, else the system tool. +appimagetool_bin="${GOBLIN_APPIMAGETOOL:-appimagetool}" +runtime_arg=() +[ -n "${GOBLIN_APPIMAGE_RUNTIME:-}" ] && runtime_arg=(--runtime-file "${GOBLIN_APPIMAGE_RUNTIME}") +ARCH=x86_64 "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}" +echo "built: ${out}" diff --git a/locales/de.yml b/locales/de.yml new file mode 100644 index 00000000..2e250766 --- /dev/null +++ b/locales/de.yml @@ -0,0 +1,808 @@ +lang_name: Deutsch +copy: Kopieren +paste: Einfügen +continue: Weiter +complete: Fertig +error: Error +retry: Erneut versuchen +close: Schließen +change: Ändern +show: Zeigen +delete: Löschen +clear: Clear +create: Erstellen +id: ID +kernel: Kernel +settings: Einstellungen +language: Sprache +scan: Scannen +qr_code: QR-Code +scan_qr: QR-Code scannen +repeat: wiederholen +scan_result: Scan Ergebnis +back: zurück +share: teilen +theme: 'Theme:' +dark: Dunkel +light: Hell +file: Datei +choose_file: Datei auswählen +choose_folder: Ordner auswählen +crash_report: Absturzbericht +crash_report_warning: Anwendung wurde beim letzten Mal unerwartet geschlossen, Sie können den Absturzbericht mit Entwicklern teilen. +confirmation: Bestätigung +enter_url: URL eingeben +max_short: MAX +files_location: Dateistandort +moving_files: Dateien verschieben +wrong_path_error: Falscher Weg angegeben +check_updates: Suchen Sie beim Start nach Updates +update_available: Update ist verfügbar! +changelog: 'Wechselbuch:' +wallets: + await_conf_amount: Erwarte Bestätigung + await_fin_amount: Warten auf die Fertigstellung + locked_amount: Gesperrt + txs_empty: 'Um Geld manuell oder per Transport zu empfangen oder zu senden, verwenden Sie die Schaltflächen %{message} oder %{transport} unten auf dem Bildschirm. Um die Wallet-Einstellungen zu ändern, drücken Sie %{settings}.' + title: Goblin + create_desc: Erstellen oder importieren Sie ein bestehendes Wallet mit dem Seed-Phrase. + add: Wallet hinzufügen + name: 'Name:' + pass: 'Passwort:' + pass_empty: Wallet Passwort eingeben + current_pass: 'Aktuelles Passwort:' + new_pass: 'Neues Passwort:' + min_tx_conf_count: 'Mindestanzahl an Bestätigungen für Transaktionen:' + recover: Wiederherstellen + recovery_phrase: Wiederherstellungssatz + words_count: 'Wortanzahl:' + enter_word: 'Wort #%{number} eingeben:' + not_valid_word: Das eingegebene Wort ist ungültig + not_valid_phrase: Der eingegebene Satz ist ungültig + create_phrase_desc: Schreiben Sie Ihre Wiederherstellungsphrase sicher auf und speichern Sie sie. + restore_phrase_desc: Geben Sie Wörter aus Ihrer gespeicherten Wiederherstellungsphrase ein. + setup_conn_desc: Wählen Sie aus, wie Ihr Wallet eine Verbindung zum Netzwerk herstellt. + conn_method: Verbindungsmethode + ext_conn: 'Externe Verbindungen:' + add_node: Node hinzufügen + node_url: 'Node URL:' + node_secret: 'API Secret (optional):' + invalid_url: Die eingegebene URL ist ungültig + open: Wallet öffnen + wrong_pass: Das eingegebene Passwort ist falsch + locked: Gesperrt + unlocked: Entsperrt + enable_node: 'Aktivieren Sie die integrierte Node, um das Wallet zu verwenden, oder ändern Sie die Verbindungseinstellungen, indem Sie unten auf dem Bildschirm %{settings} auswählen.' + node_loading: 'Das Wallet wird nach der synchronisation der integrierten Node geladen. Sie können die Verbindungseinstellungen ändern, indem Sie unten auf dem Bildschirm %{settings} auswählen.' + loading: Wird geladen + closing: Schließen + checking: Überprüfung + default_wallet: Standard-Wallet + new_account_desc: 'Namen des neuen Accounts eingeben:' + wallet_loading: Wallet wird geladen + wallet_closing: Wallet schließen + wallet_checking: Wallet prüfen + tx_loading: Laden von Transaktionen + default_account: Standardaccount + accounts: Accounts + tx_sent: Gesendet + tx_received: Erhalten + tx_sending: Senden + tx_receiving: Erhalten + tx_confirming: Erwarte Bestätigung + tx_canceled: Abgebrochen + tx_cancelling: Abbrechen + tx_finalizing: Finalisierung + tx_posting: Buchungsvorgang + tx_confirmed: Bestätigt + txs: Transaktionen + tx: Transaktion + messages: Nachrichten + transport: Transport + input_slatepack_desc: 'Geben Sie eine Nachricht ein, um eine Antwort zu erstellen oder die Transaktion abzuschließen:' + parse_slatepack_err: 'Bei der Verarbeitung der Nachricht ist ein Fehler aufgetreten. Überprüfen Sie die Eingabedaten:' + pay_balance_error: 'Der Kontostand reicht nicht aus, um %{amount} ツ und die Netzwerkgebühr zu bezahlen.' + parse_i1_slatepack_desc: 'Um %{amount} zu zahlen, senden Sie diese Nachricht an den Empfänger:' + parse_i2_slatepack_desc: 'Schließen Sie die Transaktion ab, um %{amount} ツ zu erhalten:' + parse_i3_slatepack_desc: 'Transaktion posten, um den Erhalt von %{amount} abzuschließen ツ:' + parse_s1_slatepack_desc: 'Um %{amount} zu erhalten, senden Sie diese Nachricht an den Absender:' + parse_s2_slatepack_desc: 'Schließen Sie die Transaktion ab, um %{amount} ツ zu senden:' + parse_s3_slatepack_desc: 'Transaktion posten, um das Senden von %{amount} abzuschließen ツ:' + resp_slatepack_err: 'Beim Erstellen der Antwort ist ein Fehler aufgetreten. Überprüfen Sie die Eingabedaten:' + resp_exists_err: 'Eine solche Transaktion existiert bereits.' + resp_canceled_err: 'Eine solche Transaktion wurde schon abgebrochen.' + create_request_desc: 'Erstellen Sie eine Anfrage zum Senden oder Empfangen der Gelder:' + send_request_desc: 'Sie haben eine Anfrage zum Senden von %{amount} ツ erstellt. Senden Sie diese Nachricht an den Empfänger:' + send_slatepack_err: Beim Erstellen der Anfrage zum Senden von Geldern ist ein Fehler aufgetreten. Überprüfen Sie die Eingabedaten. + invoice_desc: 'Sie haben eine Anfrage zum Erhalt von %{amount} ツ erstellt. Senden Sie diese Nachricht an den Absender der Gelder:' + invoice_slatepack_err: Bei der Rechnungsstellung ist ein Fehler aufgetreten, überprüfen Sie die Eingabedaten. + finalize_slatepack_err: 'Bei der Finalisierung ist ein Fehler aufgetreten. Überprüfen Sie die Eingabedaten:' + finalize: Abschließen + use_dandelion: Dandelion verwenden + enter_amount_send: 'Sie haben %{amount} ツ. Geben Sie den zu sendenden Betrag ein:' + enter_amount_receive: 'Geben Sie den zu erhaltenden Betrag ein:' + recovery: Wiederherstellung + repair_wallet: Wallet reparieren + repair_desc: Überprüfen Sie ein Wallet und reparieren und stellen Sie bei Bedarf fehlende Ausgaben wieder her. Dieser Vorgang wird einige Zeit dauern. + repair_unavailable: Sie benötigen eine aktive Verbindung zum Knoten und eine abgeschlossene Wallet-Synchronisierung. + delete: Wallet löschen + delete_conf: Sind Sie sicher, dass Sie das Wallet löschen möchten? + delete_desc: Stellen Sie sicher, dass Sie Ihre Wiederherstellungsphrase gespeichert haben, um auf Gelder zugreifen zu können. + wallet_loading_err: 'Bei der Synchronisierung des Wallets ist ein Fehler aufgetreten. Sie können es erneut versuchen oder die Verbindungseinstellungen ändern, indem Sie unten auf dem Bildschirm %{settings} auswählen.' + wallet: Wallet + send: Senden + receive: Empfangen + settings: Wallet Einstellungen + tx_send_cancel_conf: 'Sind Sie sicher, dass Sie das Senden von %{amount} ツ abbrechen wollen?' + tx_receive_cancel_conf: 'Sind Sie sicher, dass Sie das Empfangen von %{amount} ツ abbrechen wollen?' + rec_phrase_not_found: Wiederhestellungsphrase nicht gefunden. + restore_wallet_desc: Stellen Sie das Wallet wieder her, indem Sie alle Dateien löschen. Wenn die normale Reparatur nicht geholfen hat, müssen Sie Ihr Wallet erneut öffnen. + fee_base_desc: 'Gebühr (basiswert%{value}):' + payment_proof: Zahlungsnachweis + payment_proof_desc: 'Geben Sie den erhaltenen Zahlungsnachweis ein, um die Transaktion zu verifizieren:' + payment_proof_valid: 'Der eingegebene Zahlungsnachweis ist gültig:' + payment_proof_error: 'Der eingetragene Zahlungsnachweis ist nicht gültig:' + tx_delete_confirmation: Bist du sicher, dass du die Transaktion aus dem Verlauf löschen möchtest? +transport: + desc: 'Transport verwenden, um Nachrichten synchron zu empfangen oder zu senden:' + tor_network: Tor Netzwek + connected: verbunden + connecting: verbinden + disconnecting: Verbindung trennen + conn_error: Verbindungsproblem + disconnected: Verbindung getrennt + receiver_address: 'Empfängeraddresse:' + incorrect_addr_err: 'Eingegebene Addresse ist inkorrekt:' + tor_send_error: Beim Senden über Tor ist ein Fehler aufgetreten. Stellen Sie sicher, dass der Empfänger online ist. Die Transaktion wurde abgebrochen. + tor_autorun_desc: Gibt an, ob beim Öffnen des Wallets der Tor-Dienst gestartet werden soll, um Transaktionen synchron zu empfangen. + tor_sending: Sende über Tor + tor_settings: Tor Einstellungen + bridges: Brücken + bridges_desc: Richten Sie Brücken ein, um die Zensur des Tor-Netzwerks zu umgehen, wenn die normale Verbindung nicht funktioniert. + bin_file: 'Binärdatei:' + conn_line: 'Verbindungsleitung:' + bridges_disabled: Brücken deaktiviert + bridge_name: 'Brücke %{b}' +network: + self: Netzwerk + type: 'Netzwerk Typ:' + mainnet: Main + testnet: Test + connections: Verbindungen + node: Integrierte Node + metrics: Metriken + mining: Mining + settings: Node Einstellungen + enable_node: Node aktivieren + autorun: Autorun + disabled_server: 'Aktivieren Sie die integrierte Node oder fügen Sie eine weitere Verbindungsmethode hinzu, indem Sie oben links auf dem Bildschirm auf %{dots} drücken.' + no_ips: Auf Ihrem System sind keine IP-Adressen verfügbar. Der Server kann nicht gestartet werden. Überprüfen Sie Ihre Netzwerkkonnektivität. + available: Verfügbar + not_available: Nicht verfügbar + availability_check: Verfügbarkeitsprüfung + android_warning: Achtung an Android-Benutzer. Um integrierte Nodes erfolgreich zu synchronisieren, müssen Sie in den Systemeinstellungen Ihres Telefons den Zugriff auf Benachrichtigungen zulassen und die Beschränkungen für die Akkunutzung für die Grim-Anwendung entfernen. Dies ist ein notwendiger Vorgang, damit die Anwendung im Hintergrund korrekt funktioniert. +sync_status: + node_restarting: Node wird neu gestartet + node_down: Node ist ausgefallen + initial: Node startet + no_sync: Node läuft + awaiting_peers: Warten auf Peers + header_sync: Header werden heruntergeladen + header_sync_percent: 'Header werden heruntergeladen: %{percent}%' + tx_hashset_pibd: Downloadstatus (PIBD) + tx_hashset_pibd_percent: 'Downloadstatus (PIBD): %{percent}%' + tx_hashset_download: Downloadstatus + tx_hashset_download_percent: 'Downloadstatus: %{percent}%' + tx_hashset_setup_history: 'Vorbereitungsstatus (Verlauf): %{percent}%' + tx_hashset_setup_position: 'Vorbereitungsstatus (Position): %{percent}%' + tx_hashset_setup: Vorbereitungszustand + tx_hashset_range_proofs_validation: 'Validierungsstatus (range proofs): %{percent}%' + tx_hashset_kernels_validation: 'Validierungsstatus (Kernel): %{percent}%' + tx_hashset_save: Finalisierender Chainstatus + body_sync: Blöcke herunterladen + body_sync_percent: 'Blöcke herunterladen: %{percent}%' + shutdown: Node wird heruntergefahren +network_node: + header: Header + block: Block + hash: Hash + height: Höhe + difficulty: Schwierigkeit + time: Zeit + main_pool: Hauptpool + stem_pool: Stem-Pool + data: Daten + size: Größe (GB) + peers: Peers + error_clean: + resync: Neu synchronisieren + error_p2p_api: 'Während der Initialisierung des %{p2p_api}-Servers ist ein Fehler aufgetreten. Überprüfen Sie die %{p2p_api}-Einstellungen, indem Sie unten auf dem Bildschirm %{settings} auswählen.' + error_config: 'Während der Initialisierung der Konfiguration ist ein Fehler aufgetreten. Überprüfen Sie die Einstellungen, indem Sie unten auf dem Bildschirm %{settings} auswählen.' + error_unknown: 'Während der Initialisierung ist ein Fehler aufgetreten. Überprüfen Sie die integrierten Knoteneinstellungen, indem Sie unten auf dem Bildschirm %{settings} auswählen oder erneut synchronisieren.' +network_metrics: + loading: Metriken werden nach der Synchronisierung verfügbar sein + emission: Emission + inflation: Inflation + supply: Supply + block_time: Blockzeit + reward: Belohnung + difficulty_window: 'Schwierigkeitsfenster %{size}' +network_mining: + loading: Mining wird nach der Synchronisierung verfügbar sein + info: 'Mining-Server aktiviert ist, können Sie seine Einstellungen ändern, indem Sie unten auf dem Bildschirm %{settings} wählen. Die Daten werden aktualisiert, wenn Geräte angeschlossen sind.' + restart_server_required: Ein Neustart des Servers ist erforderlich, um die Änderungen zu übernehmen. + rewards_wallet: Brieftasche für Belohnungen + server: Stratum Server + address: Addresse + miners: Miner + devices: Geräte + blocks_found: Gefundene Blöcke + hashrate: 'Hashrate (C%{bits})' + connected: Verbunden + disconnected: Getrennt +network_settings: + change_value: Wert ändern + stratum_ip: 'Stratum IP Addresse:' + stratum_port: 'Stratum Port:' + port_unavailable: Der angegebene Port ist nicht verfügbar + restart_node_required: Ein Neustart der Node ist erforderlich, um die Änderungen zu übernehmen. + choose_wallet: Wählen Wallet + stratum_wallet_warning: Wallet muss geöffnet sein, um Belohnungen zu erhalten. + enable: Aktivieren + disable: Deaktivieren + restart: Neustarten + server: Server + api_ip: 'API IP Addresse:' + api_port: 'API Port:' + api_secret: 'Rest API and V2 Owner API token:' + foreign_api_secret: 'Fremdes API token:' + disabled: Deaktiviert + enabled: Aktiviert + ftl: 'Das Future Time Limit (FTL):' + ftl_description: Begrenzung, wie weit in der Zukunft, relativ zur lokalen Zeit eines Knotens in Sekunden, der Zeitstempel eines neuen Blocks liegen darf, damit der Block akzeptiert wird. + not_valid_value: Eingegebener Wert ist nicht gültig + full_validation: Vollständige Validierung + full_validation_description: Ob bei der Verarbeitung jedes Blocks eine vollständige Kettenvalidierung durchgeführt werden soll (außer bei der Synchronisierung). + archive_mode: Archiv Modus + archive_mode_desc: Führen Sie den Knoten im vollständigen Archivmodus aus (für die Synchronisierung wird mehr Speicherplatz und Zeit benötigt). + attempt_time: 'Zeit des Miningsversuches (in Sekunden):' + attempt_time_desc: Die Zeitspanne, in der versucht wird, eine bestimmte Kopfzeile abzubauen, bevor der Abbau gestoppt und die Transaktionen erneut aus dem Pool gesammelt werden + min_share_diff: 'Der Mindestschwierigkeitsgrad des Shares:' + reset_settings_desc: Nodeeinstellungen auf Standardwerte zurücksetzen + reset_settings: Einstellungen zurücksetzen + reset: zurücksetzen + tx_pool: Transaktionspool + pool_fee: 'Grundgebühr, die in den Pool aufgenommen wird:' + reorg_period: 'Aufbewahrungsdauer des Reorg-Caches (in Minuten):' + max_tx_pool: 'Maximale Anzahl von Transaktionen im Pool:' + max_tx_stempool: 'Maximale Anzahl von Transaktionen im Stamm-Pool:' + max_tx_weight: 'Maximales Gesamtgewicht der Transaktionen, die zur Bildung eines Blocks ausgewählt werden können:' + epoch_duration: 'Epochendauer (in Sekunden):' + embargo_timer: 'Embargo-Timer (in Sekunden):' + aggregation_period: 'Aggregationszeitraum (in Sekunden):' + stem_probability: 'Wahrscheinlichkeit der Stem-Phase:' + stem_txs: Stem Transaktionen + p2p_server: P2P Server + p2p_port: 'P2P port:' + add_seed: DNS-Seed hinzufügen + seed_address: 'DNS Seed Addresse:' + add_peer: Peer hinzufügen + peer_address: 'Peer Addresse:' + peer_address_error: 'Geben Sie die IP-Adresse oder den DNS-Namen (stellen Sie sicher, dass der angegebene Host verfügbar ist) im richtigen Format ein, z. B: 192.168.0.1:1234 oder example.com:5678' + default: Standardeinstellung + allow_list: Erlaubt-Liste + allow_list_desc: Nur mit diesen Peers verbinden + deny_list: Ablehnungsliste + deny_list_desc: Niemals mit diesen Peers verbinden + favourites: Favoriten + favourites_desc: Eine Liste der bevorzugten Peers, mit denen eine Verbindung hergestellt werden soll. + ban_window: 'Wie viel Zeit (in Sekunden) ein gesperrter Peer gesperrt bleiben soll:' + ban_window_desc: Die Entscheidung über das Verbot trifft der Knoten auf der Grundlage der Korrektheit der von der Gegenstelle erhaltenen Daten. + max_inbound_count: 'Maximale Anzahl der eingehenden Peer-Verbindungen:' + max_outbound_count: 'Maximale Anzahl von ausgehenden Peer-Verbindungen:' + reset_data_desc: Reset-Knotendaten. Verwenden Sie diese Funktion nur, wenn es Probleme mit der Synchronisation gibt. + reset_data: Daten zurücksetzten +modal: + cancel: Abbrechen + save: Speichern + add: Hinzufügen +modal_exit: + description: Sind Sie sicher, dass Sie die Anwendung beenden wollen? + exit: Schließen +app_settings: + proxy: Proxy + proxy_desc: Lohnt es sich, einen Proxy für Netzwerkanfragen von der Anwendung zu verwenden. +keyboard: + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 5 + 6: 6 + 7: 7 + 8: 8 + 9: 9 + 0: 0 + 01: ß + q: q + w: w + e: e + r: r + t: t + y: z + u: u + i: i + o: o + p: p + p1: ü + a: a + s: s + d: d + f: f + g: g + h: h + j: j + k: k + l: l + l1: ö + l2: ä + z: y + x: x + c: c + v: v + b: b + n: n + m: m + m1: ',' + m2: . + m3: '/' +goblin: + home: + anonymous: "Anonym" + connected_nym: "Über Nym verbunden" + nym_ready: "Nym bereit · Relays…" + connecting_nym: "Verbinde mit Nym…" + cant_reach_node: "Node nicht erreichbar" + node_synced: "Node synchronisiert" + syncing: "Synchronisiere…" + block: "Block %{height}" + waiting_for_chain: "Warte auf Chain…" + nav_wallet: "Wallet" + nav_pay: "Zahlen" + nav_activity: "Aktivität" + nav_receive: "Empfangen" + nav_settings: "Einstellungen" + activity: "Aktivität" + empty_title: "Noch keine Aktivität" + empty_sub: "Sende oder empfange grin, um zu starten." + recent: "Zuletzt" + scan_to_pay: "Zum Zahlen scannen" + type_amount: "Betrag eingeben" + request: "Anfordern" + pay: "Zahlen" + enter_amount: "Betrag zum Zahlen oder Anfordern eingeben" + activity: + canceled: "abgebrochen" + pending: "ausstehend" + earlier: "Früher" + today: "Heute" + yesterday: "Gestern" + title: "Aktivität" + requests: "Anfragen" + empty_title: "Noch keine Aktivität" + empty_sub: "Deine Zahlungen erscheinen hier." + pending_header: "Ausstehend" + receipt: + title: "Beleg" + not_found: "Transaktion nicht gefunden" + for_note: "Für %{note}" + details: "Transaktionsdetails" + canceled: "Abgebrochen" + expired: "Abgelaufen" + funds_returned: "Guthaben zurückerstattet" + complete: "Abgeschlossen" + payment_received: "Zahlung empfangen" + payment_sent: "Zahlung erfolgreich gesendet" + pending: "Ausstehend" + confs: "%{c}/%{r} Bestätigungen" + waiting_to_confirm: "Warte auf Bestätigung" + paying: "Zahlung läuft…" + you: "Du" + to: "An" + from: "Von" + nostr: "nostr" + fee_none: "Keine" + network_fee: "Netzwerkgebühr" + privacy: "Privatsphäre" + privacy_value: "Mimblewimble + Nym" + transaction: "Transaktion" + cancel_request: "Anfrage abbrechen" + cancel_send: "Zahlung abbrechen" + cancel_send_confirm: "Zum Abbrechen erneut tippen — sie könnten sie noch erhalten" + cancel_send_done: "Zahlung abgebrochen — dein Guthaben ist wieder verfügbar" + cancel_send_too_late: "Diese Zahlung ist bereits durchgegangen und kann nicht abgebrochen werden" + waiting_to_receive: "Warte, bis %{name} empfängt…" + request: + title: "%{name} fordert an" + approve: "Annehmen" + decline: "Ablehnen" + review_title: "Anfrage prüfen" + hold_to_accept: "Zum Annehmen halten" + hold_accept_hint: "Halte gedrückt, um diese Anfrage zu bezahlen" + receive: + title: "Empfangen" + requesting: "Fordere %{amt}%{tsu} an — teilen, um bezahlt zu werden" + clear_request: "Anfrage löschen" + share_handle: "Teile deinen Handle, um bezahlt zu werden" + copied: "Kopiert" + copy_nostr_id: "nostr-ID kopieren" + copy_address: "Adresse kopieren" + copy_npub: "npub kopieren" + share_message: "Bezahl mich auf Goblin (goblin.st) — %{npub}" + privacy_note: "Dein Benutzername ist öffentlich. Zahlungsinhalte bleiben im Netzwerk verschlüsselt." + profile: + title: "Profil" + activity: "Aktivität" + no_activity: "Noch keine Aktivität mit ihnen." + unblock: "Entsperren" + block: "Sperren" + blocked_blurb: "Gesperrt — ihre Zahlungen und Anfragen werden verworfen." + block_blurb: "Sperren verwirft eingehende Zahlungen und Anfragen von ihnen." + settings: + title: "Einstellungen" + connected_nostr: "Mit nostr verbunden" + connecting_relays: "Verbinde mit Relays…" + identity: "Identität" + copy_npub: "npub kopieren (öffentlich)" + rotate_key: "nostr-Schlüssel wechseln" + import_identity: "Identität importieren (.backup / nsec)" + backup_note: "Gerät wechseln? Sichere BEIDES: deine Seed-Phrase (Guthaben) und deine Identitäts-.backup-Datei (Name + Schlüssel)." + wallet: "Wallet" + display_unit: "Anzeigeeinheit" + relays: "Relays" + node: "Node" + slatepacks: "Slatepacks" + slatepacks_value: "Manuelle Transaktion" + lock_wallet: "Wallet sperren" + switch_wallet: "Wallet wechseln" + advanced: "Erweitert" + privacy: "Privatsphäre" + mixnet_routing: "Mixnet-Routing" + messages_lookups: "Nachrichten & Abfragen" + auto_accept: "Automatisch annehmen" + pairing: "Kopplung" + accept_anyone: "Jeder" + accept_contacts: "Nur Kontakte" + accept_ask: "Immer fragen" + requests: "Anfragen" + incoming_requests: "Eingehende Anfragen" + incoming_requests_sub: "Erlaube anderen, Geld von dir anzufordern" + appearance: "Erscheinungsbild" + theme: "Design" + theme_light: "Hell" + theme_dark: "Dunkel" + theme_yellow: "Gelb" + archive: "Archiv" + export_archive: "Archiv exportieren" + wipe_history: "Zahlungsverlauf löschen" + about: "Über" + goblin: "Goblin" + build: "Build %{build}" + network: "Netzwerk" + network_value: "MW + Nym mixnet + nostr" + third_party: "Drittanbieter" + grim: "GRIM (Upstream-Wallet)" + grin_node: "Grin-Node" + sp_intro: "Erweitert — rohe slatepacks von Hand austauschen, so wie GRIM es macht. Nur nutzen, wenn du nicht über einen username zahlen oder bezahlt werden kannst." + sp_receive_group: "Empfangen oder abschließen" + sp_receive_blurb: "Füge einen slatepack ein, den dir jemand gegeben hat. Goblin empfängt die Zahlung, begleicht die Rechnung oder schließt sie ab und sendet sie." + sp_process: "Slatepack verarbeiten" + sp_paste_first: "Füge zuerst einen slatepack ein." + sp_reply_ready: "Antwort bereit — sende sie an den Absender zurück." + sp_finalizing: "Schließe ab und sende an die Chain…" + sp_create_group: "Zahlung erstellen" + sp_create_blurb: "Erstelle einen slatepack zum Übergeben. Der Empfänger nimmt ihn an, sendet die Antwort zurück, und du schließt sie oben ab." + sp_amount_hint: "Betrag in grin" + sp_addr_hint: "Empfängeradresse (optional)" + sp_create: "Slatepack erstellen" + sp_ready: "Slatepack bereit — übergib ihn dem Empfänger." + sp_amount_gt_zero: "Gib einen Betrag größer als null ein." + sp_to_send: "Zu sendender slatepack" + sp_copy: "Slatepack kopieren" + rotate_line1: "• Du bekommst einen brandneuen ZUFÄLLIGEN Schlüssel; das alte npub empfängt nichts mehr. Es gibt keine Ableitungskette zwischen beiden." + rotate_line2: "• Der neue Schlüssel ist NICHT aus deinem Seed wiederherstellbar — sichere die neue nsec direkt nach dem Wechsel." + rotate_line3: "• Dein Benutzername wird FREIGEGEBEN — beanspruche direkt danach denselben oder einen neuen Namen (sobald frei, kann ihn auch jede andere Person nehmen)." + rotate_line4: "• Zahlungen, die noch an den alten Schlüssel unterwegs sind, WERDEN gestört — warte zuerst, bis ausstehende Zahlungen abgeschlossen sind." + rotate_line5: "• Kontakte, die dein npub direkt gespeichert haben, müssen dich neu finden — teile dein neues npub oder den neu gesicherten username." + cancel: "Abbrechen" + continue: "Weiter" + final_confirmation: "Endgültige Bestätigung" + rotate_confirm_blurb: "Dies kann in der App nicht rückgängig gemacht werden. Tippe RESET und gib dein Wallet-Passwort ein, um zu wechseln." + type_reset: "RESET tippen" + wallet_password: "Wallet-Passwort" + rotate_key_btn: "Schlüssel wechseln" + rotating_key: "Wechsle Schlüssel…" + key_rotated: "Schlüssel gewechselt" + new_npub: "Neues npub: %{npub}" + backup_new_key: "Sichere jetzt den NEUEN geheimen Schlüssel — dein Seed kann ihn nicht wiederherstellen." + copy_new_nsec: "Neues nsec-Backup kopieren" + done: "Fertig" + rotation_failed: "Wechsel fehlgeschlagen" + close: "Schließen" + import_identity_title: "Identität importieren" + import_blurb: "Ersetzt die nostr-Identität dieser Wallet — wähle eine GOBLIN-.backup-Datei oder füge einen nsec ein. Eine Sicherung stellt auch Benutzername und Verlauf wieder her. Sichere zuerst den aktuellen Schlüssel, falls du ihn noch brauchst." + import_nsec_hint: "nsec1… oder eingefügte Sicherung" + backup_password_hint: "Backup-Passwort (nur wenn anderswo exportiert)" + import_btn: "Importieren" + importing: "Importiere…" + identity_replaced: "Identität ersetzt" + now_using: "Jetzt aktiv: %{npub}" + import_failed: "Import fehlgeschlagen" + name_authority: "Namensautorität" + name_authority_title: "Namensautorität ändern" + name_authority_blurb: "Der Server, der Namen registriert und verifiziert. Auf eine andere Instanz zeigen, um dort gehostete Namen zu nutzen und zu bezahlen." + name_authority_invalid: "Vollständige URL eingeben (https://…)." + reset: "Zurücksetzen" + save: "Speichern" + backup_file: "In Datei sichern" + choose_backup_file: "Eine .backup-Datei wählen" + backup_read_failed: "Datei konnte nicht gelesen werden." + backup_saved: "Sicherung gespeichert" + backup_saved_sub: "Bewahre die .backup-Datei sicher auf — wer sie UND dein Passwort hat, kann deine Identität wiederherstellen." + backup_file_title: "Identität sichern" + backup_file_blurb: "Erstellt eine verschlüsselte .backup-Datei mit Benutzername und Schlüssel. Gib dein Wallet-Passwort ein, um sie zu versiegeln." + backup_write_failed: "Datei konnte nicht gespeichert werden." + create_backup: "Sicherung erstellen" + registered: "%{name} registriert" + released_msg: "Freigegeben — der Name ist frei" + release_confirm: "%{name} freigeben?" + release_blurb: "Sobald er frei ist, ist er verfügbar — jeder kann ihn beanspruchen, auch dein nächster rotierter Schlüssel. Du kannst 10 Minuten lang keinen anderen Benutzernamen registrieren." + releasing: "Gebe frei…" + keep_it: "Behalten" + release_it: "Freigeben" + username: "Benutzername" + username_note: "Wird als you angezeigt. Öffentlich auf goblin.st. Zahlungen bleiben verschlüsselt." + release_username: "Benutzername freigeben" + pick_username: "Benutzernamen wählen — optional" + working: "Arbeite…" + claim: "Sichern" + err_just_taken: "Dieser Benutzername wurde gerade vergeben" + err_cooldown: "Du hast kürzlich einen Benutzernamen freigegeben — du kannst innerhalb von 10 Minuten einen neuen registrieren." + err_unreachable: "goblin.st nicht erreichbar — Verbindungsproblem. Versuche es erneut." + err_release: "Freigabe fehlgeschlagen: %{err}" + avail_available: "Verfügbar!" + avail_taken: "Vergeben" + avail_reserved: "Reserviert" + avail_invalid: "Namen haben 3–20 Zeichen: a–z, 0–9, _ oder -" + avail_quarantined: "Nicht verfügbar" + avail_unknown: "Prüfung fehlgeschlagen — Verbindungsproblem. Versuche es erneut." + advanced: + title: "Erweitert" + intro: "Wallet-Werkzeuge auf niedriger Ebene von GRIM. Normalerweise brauchst du diese nicht." + own_node_desc: "Synchronisiere einen vollständigen Grin-Node auf diesem Gerät, statt einem öffentlichen zu vertrauen." + own_node_active: "Eigener Node läuft" + repair: "Wallet reparieren" + repair_desc: "Die Kette neu scannen und fehlende Outputs wiederherstellen. Das kann dauern." + repair_unavailable: "Benötigt zuerst eine synchronisierte Node-Verbindung." + repairing: "Repariere… %{pct}%" + restore: "Wallet wiederherstellen" + restore_desc: "Lokale Daten löschen und aus deinem Seed neu aufbauen. Nutze das, wenn eine Reparatur nicht half — danach öffnest du die Wallet erneut." + restore_confirm: "Zum Wiederherstellen erneut tippen" + show_phrase: "Wiederherstellungsphrase" + phrase_desc: "Deine 24 grin-Seed-Wörter — der einzige Weg, Guthaben wiederherzustellen. Halte sie offline und privat." + reveal: "Phrase anzeigen" + hide: "Verbergen" + password: "Wallet-Passwort" + wrong_password: "Falsches Passwort." + delete: "Wallet löschen" + delete_desc: "Diese Wallet dauerhaft von diesem Gerät entfernen. Ohne deinen Seed sind Guthaben nicht wiederherstellbar." + delete_confirm: "Zum Löschen erneut tippen" + manage_node: "Node-Verbindung verwalten" + repair_confirm: "Ja, jetzt reparieren" + repair_confirm_note: "Die Reparatur scannt die Chain neu und kann einige Minuten dauern." + restore_confirm_note: "Dies löscht lokale Daten und baut sie aus deinem Seed neu auf — das kann einige Minuten dauern." + privacy: + title: "Netzwerk-Privatsphäre" + intro: "Goblin sendet seinen privaten Datenverkehr durch das Nym mixnet — ein Netzwerk mit fünf Sprüngen, das verbirgt, wer mit wem kommuniziert, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann." + payments: "Zahlungen" + payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt." + usernames: "usernames" + usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st." + price_avatars: "Preis" + price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen." + over_mixnet: "Über das mixnet" + direct_connection: "Direkte Verbindung" + grin_node: "Grin-Node" + grin_node_blurb: "Block-Synchronisierung und Übertragung deiner Transaktion ins Netzwerk. Dies sind öffentliche Chain-Daten, für alle gleich, und nicht mit deiner Identität verknüpft." + pairing: + title: "Kopplung" + intro: "Womit dein Guthaben und deine Beträge verglichen werden." + pair_with: "Koppeln mit" + rates_note: "Kurse werden über das Nym mixnet abgerufen, nur solange eine Kopplung aktiv ist — aus bedeutet, dass keine Kursanfrage dein Gerät verlässt." + relays: + title: "Relays" + intro: "Zahlungsnachrichten werden an jedes Relay unten gespiegelt; ein erreichbares Relay genügt zum Empfangen." + your_relays: "Deine Relays" + add_relay: "Relay hinzufügen" + add_relay_btn: "Relay hinzufügen" + save_reconnect: "Speichern & neu verbinden" + none: "keine" + count: "%{n} Relays" + node: + title: "Node" + connection: "Verbindung" + integrated: "Integrierter Node" + applies_after: "Wird wirksam, nachdem das Wallet gesperrt und wieder entsperrt wurde." + add_external: "Externen Node hinzufügen" + api_secret_hint: "API-Secret (optional)" + add_node: "Node hinzufügen" + integrated_host: "integrierter Node" + summary_syncing: "%{conn} · synchronisiere" + summary_block: "Block %{height} · %{conn}" + nips: + title: "nostr & NIPs" + intro1: "Goblin spricht nostr — ein offenes Protokoll signierter Nachrichten, die über einfache Relay-Server weitergereicht werden. Dein Wallet trägt seine eigene nostr-Identität: einen eigenständigen Zufallsschlüssel, bewusst unabhängig von deinem Guthaben und Seed gehalten. Jede Zahlung reist als Ende-zu-Ende-verschlüsselte Direktnachricht zwischen Identitäten, mit dem slatepack im Inneren." + intro2: "goblin.st ist Goblins Namensdienst: Das Sichern eines Benutzernamens veröffentlicht dort eine Name → Schlüssel-Zuordnung (NIP-05), sodass Leute you statt eines langen npub bezahlen können. Der Benutzername ist öffentlich; Zahlungsinhalte sind es nie. NIPs sind die Bausteine des Protokolls — tippe auf einen, um die Spezifikation zu lesen." + n05_title: "Namen" + n05_blurb: "Ordnet username@goblin.st deinem Schlüssel zu, sodass Handles wie Adressen funktionieren." + n17_title: "Private Nachrichten" + n17_blurb: "Die verschlüsselte DM-Hülle, in der jede Zahlung reist." + n44_title: "Verschlüsselung" + n44_blurb: "Die authentifizierte Chiffre, die in diesen Nachrichten verwendet wird." + n49_title: "Schlüsselverschlüsselung" + n49_blurb: "Wie der geheime Schlüssel im Ruhezustand gespeichert wird, gesperrt durch dein Passwort." + n59_title: "Gift Wrap" + n59_blurb: "Verpackt Nachrichten, sodass Relays nicht sehen können, wer mit wem kommuniziert." + n98_title: "HTTP-Auth" + n98_blurb: "Signiert die Benutzernamen-Registrierungsanfrage an goblin.st." + onboarding: + intro: + private_money_head: "Privates Geld" + private_money_body: "Goblin ist ein Wallet für grin — digitales Bargeld ohne Beträge oder Adressen auf seiner Chain." + send_like_message_head: "Senden wie eine Nachricht" + send_like_message_body: "Zahle an einen username oder npub und es kommt als Ende-zu-Ende-verschlüsselte Nachricht über nostr und das Nym mixnet an — niemand dazwischen sieht den Betrag oder die Beteiligten." + yours_alone_head: "Nur deins" + yours_alone_body: "Schlüssel, Namen und Verlauf bleiben auf diesem Gerät. Basiert auf dem GRIM-Wallet." + get_started: "Loslegen" + footnote: "Dauert etwa eine Minute. Du kannst später alles ändern." + node: + kicker: "SCHRITT 1 VON 3 · NETZWERK" + title: "Wie soll Goblin\ndie Chain beobachten?" + own_title: "Eigenen Node betreiben" + own_badge: "Privat" + own_body: "Vertraut niemandem — dein Wallet prüft die Chain selbst. Synchronisiert im Hintergrund, während du die Einrichtung beendest." + connect_title: "Mit einem Node verbinden" + connect_badge: "Sofort" + connect_body: "Kein Warten auf Sync. Der gewählte Node kann die Abfragen deines Wallets sehen." + changeable: "Jederzeit änderbar unter Einstellungen → Node." + continue: "Weiter" + url_invalid: "Node-URL muss mit http:// oder https:// beginnen" + wallet: + kicker: "SCHRITT 2 VON 3 · WALLET" + title: "Richte dein Wallet ein" + create_new: "Neu erstellen" + restore_from_seed: "Aus Seed wiederherstellen" + name_hint: "Wallet-Name" + password_hint: "Passwort" + repeat_password_hint: "Passwort wiederholen" + restore_hint: "Halte deine Seed-Wörter bereit — du gibst sie als Nächstes ein." + create_hint: "Als Nächstes erhältst du 24 Seed-Wörter zum Aufschreiben. Sie sind das Geld — wer sie hat, hält dein Guthaben." + continue: "Weiter" + passwords_no_match: "Passwörter stimmen nicht überein" + words: + kicker: "SCHRITT 2 VON 3 · WALLET" + title_restore: "Gib deine Seed-Wörter ein" + title_create: "Schreibe diese Wörter auf" + write_down_hint: "Auf Papier, in Reihenfolge. Wer diese Wörter hat, kann dein Guthaben nehmen; ohne sie bedeutet ein verlorenes Gerät verlorenes Guthaben." + paste: "Einfügen" + scan_qr: "QR scannen" + copy_clipboard: "In Zwischenablage kopieren (vermeiden)" + restore_wallet: "Wallet wiederherstellen" + wrote_them_down: "Ich habe sie aufgeschrieben" + fill_every_word: "Fülle jedes Wort aus — tippe ein Wort an, um es zu bearbeiten, oder füge die Phrase ein." + confirm: + kicker: "SCHRITT 2 VON 3 · WALLET" + title: "Jetzt beweise es" + enter_hint: "Gib die soeben aufgeschriebenen Wörter ein. Tippe ein Wort an, um es zu tippen." + paste: "Einfügen" + create_wallet: "Wallet erstellen" + keep_going: "Weiter so — jedes Wort, in Reihenfolge." + identity: + kicker: "SCHRITT 3 VON 3 · IDENTITÄT" + title: "Deine Zahlungsidentität" + key_being_made: "Schlüssel wird erstellt…" + connected_nym: "über Nym verbunden" + connecting_nym: "verbinde über Nym…" + fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren." + clean_slate_blurb: "Lust auf einen Neuanfang? Tausche jederzeit einen brandneuen Schlüssel ein — das neue Du ist nicht mit dem alten verknüpft. Gleiches Wallet, frisches Gesicht." + pick_username: "Benutzernamen wählen — optional" + username_blurb: "Freunde zahlen an deinen Namen statt an einen langen Schlüssel. Optional — jederzeit beanspruchbar." + username_field_hint: "deinname" + working: "Arbeite…" + claim_username: "Benutzernamen sichern" + available_when_connected: "Verfügbar, sobald das mixnet verbindet — oder überspringen und später sichern." + youre: "Du bist %{name}" + claimed_title: "%{name} gehört dir" + claimed_blurb: "Freunde können dich jetzt per Namen bezahlen. Alles bereit — öffne dein Wallet." + open_wallet: "Mein Wallet öffnen" + skip_for_now: "Vorerst überspringen" + import_existing: "Schon eine Goblin-Identität? Importieren" + import_title: "Identität importieren" + import_blurb: "Füge deinen nsec ein oder wähle eine .backup-Datei, um deinen vorhandenen Schlüssel und Benutzernamen zu behalten statt diesen neuen." + errors: + cant_open: "Wallet konnte nicht geöffnet werden: %{err}" + cant_create: "Wallet konnte nicht erstellt werden: %{err}" + send: + scan_to_request: "Zum Anfordern scannen" + scan_to_pay: "Zum Zahlen scannen" + tab_scan: "Scannen" + tab_my_code: "Mein Code" + request_from: "Anfordern von" + send_to: "Senden an" + search_hint: "handle, npub oder Name" + suggested: "%{icon} Vorgeschlagen" + no_contacts: "Noch keine Kontakte. Finde jemanden über seinen handle." + no_profile: "kein Profil" + tag_contact: "Kontakt" + tag_on_nostr: "auf nostr" + searching_nostr: "Durchsuche nostr…" + unverified_title: "Unverifizierten Schlüssel bezahlen?" + unverified_body: "Für diesen Schlüssel ist kein nostr-Profil veröffentlicht — er könnte brandneu, anonym oder vertippt sein. Prüfe genau, ob es der richtige ist, bevor du sendest." + keep_looking: "Weitersuchen" + pay_anyway: "Trotzdem zahlen" + scan_not_recipient: "Dieser QR ist kein goblin-Empfänger — erwartet wurde ein npub oder handle" + scan_prompt: "Halte einen goblin-Code ins Bild, um zu aktivieren" + scan_to_pay_me: "Scannen, um mich zu bezahlen" + share_btn: "%{icon} Teilen" + share_message: "Bezahl mich auf Goblin — %{handle}\n%{link}\nnpub: %{npub}" + none_found: "Niemand gefunden für %{label}" + enter_recipient: "Gib einen handle, npub oder Namen ein" + amount_title: "Betrag" + to_name: "An %{name}" + not_enough: "Du hast nicht genug grin" + max: "Max" + note_label: "Notiz" + note_hint: "Notiz hinzufügen…" + add_note: "Notiz hinzufügen" + edit_note: "Notiz bearbeiten" + note_cancel: "Abbrechen" + note_save: "Speichern" + review_btn: "Prüfen" + confirm_request: "Anfrage bestätigen" + review_title: "Prüfen" + requesting_from: "Fordere an von %{name}" + youre_sending: "Du sendest %{name}" + row_from: "Von" + row_to: "An" + row_note: "Notiz" + row_they_pay: "Sie zahlen" + row_they_pay_val: "Nur wenn sie zustimmen" + row_delivery: "Zustellung" + row_delivery_val: "NIP-44-verschlüsselt, über Nym" + row_network_fee: "Netzwerkgebühr" + row_network_fee_val: "Von deinem Guthaben abgezogen" + row_privacy: "Privatsphäre" + row_privacy_val: "Mimblewimble + Nym" + send_request_btn: "Anfrage senden" + request_approve_hint: "Sie erhalten eine Anfrage zum Zustimmen" + hold_to_send: "Zum Senden halten" + lower_amount: "Zurückgehen und Betrag verringern" + hold_confirm_hint: "Gedrückt halten zum Bestätigen" + requesting: "Fordere an…" + sending: "Sende…" + they: "Sie" + request_blocked: "%{who} nimmt keine Anfragen an. Bitte sie, dir stattdessen grin zu senden." + failed_request_title: "Anfrage fehlgeschlagen" + failed_send_title: "Senden fehlgeschlagen" + failed_request_body: "Die Anfrage konnte nicht zugestellt werden. Bitte sie, dir stattdessen grin zu senden." + failed_send_body: "Die Zahlung wurde nicht zugestellt. Dein grin ist sicher — versuche es erneut." + try_again_btn: "Erneut versuchen" + close_btn: "Schließen" + success: + requested: "Angefordert" + sent: "Gesendet" + from: "von" + to: "an" + subtitle: "%{dir} %{who} · gerade eben" + done_btn: "Fertig" + receipt_btn: "Beleg" \ No newline at end of file diff --git a/locales/en.yml b/locales/en.yml new file mode 100644 index 00000000..aedc8d71 --- /dev/null +++ b/locales/en.yml @@ -0,0 +1,808 @@ +lang_name: English +copy: Copy +paste: Paste +continue: Continue +complete: Complete +error: Error +retry: Retry +close: Close +change: Change +show: Show +delete: Delete +clear: Clear +create: Create +id: Identifier +kernel: Kernel +settings: Settings +language: Language +scan: Scan +qr_code: QR code +scan_qr: Scan QR code +repeat: Repeat +scan_result: Scan result +back: Back +share: Share +theme: 'Theme:' +dark: Dark +light: Light +file: File +choose_file: Choose file +choose_folder: Choose folder +crash_report: Crash report +crash_report_warning: Application closed unexpectedly last time, you can share crash report with developers. +confirmation: Confirmation +enter_url: Enter URL +max_short: MAX +files_location: Files location +moving_files: Moving files +wrong_path_error: Wrong path specified +check_updates: Check for updates at startup +update_available: Update is available! +changelog: 'Changelog:' +wallets: + await_conf_amount: Awaiting confirmation + await_fin_amount: Awaiting finalization + locked_amount: Locked + txs_empty: 'To receive funds manually or over transport use %{message} or %{transport} buttons at the bottom of the screen, to change wallet settings press %{settings} button.' + title: Goblin + create_desc: Create or import existing wallet from saved recovery phrase. + add: Add wallet + name: 'Name:' + pass: 'Password:' + pass_empty: Enter the wallet password + current_pass: 'Current password:' + new_pass: 'New password:' + min_tx_conf_count: 'Minimum amount of confirmations for transactions:' + recover: Restore + recovery_phrase: Recovery phrase + words_count: 'Words count:' + enter_word: 'Enter word #%{number}:' + not_valid_word: Entered word is not valid + not_valid_phrase: Entered phrase is not valid + create_phrase_desc: Safely write down and save your recovery phrase. + restore_phrase_desc: Enter words from your saved recovery phrase. + setup_conn_desc: Choose how your wallet connects to the network. + conn_method: Connection method + ext_conn: 'External connections:' + add_node: Add node + node_url: 'Node URL:' + node_secret: 'API Secret (optional):' + invalid_url: Entered URL is invalid + open: Open the wallet + wrong_pass: Entered password is wrong + locked: Locked + unlocked: Unlocked + enable_node: 'Enable integrated node to use the wallet or change connection settings by selecting %{settings} at the bottom of the screen.' + node_loading: 'Wallet will be loaded after integrated node synchronization, you can change connection by selecting %{settings} at the bottom of the screen.' + loading: Loading + closing: Closing + checking: Checking + default_wallet: Default wallet + new_account_desc: 'Enter name of new account:' + wallet_loading: Loading wallet + wallet_closing: Closing wallet + wallet_checking: Checking wallet + tx_loading: Loading transactions + default_account: Default account + accounts: Accounts + tx_sent: Sent + tx_received: Received + tx_sending: Sending + tx_receiving: Receiving + tx_confirming: Awaiting confirmation + tx_canceled: Canceled + tx_cancelling: Cancelling + tx_finalizing: Finalizing + tx_posting: Posting + tx_confirmed: Confirmed + txs: Transactions + tx: Transaction + messages: Messages + transport: Transport + input_slatepack_desc: 'Enter received Slatepack message to create response or finalize request:' + parse_slatepack_err: 'An error occurred during reading of the message, check input:' + pay_balance_error: 'Account balance is insufficient to pay %{amount} ツ and network fee.' + parse_i1_slatepack_desc: 'To pay %{amount} ツ send this message to the receiver:' + parse_i2_slatepack_desc: 'Finalize transaction to receive %{amount} ツ:' + parse_i3_slatepack_desc: 'Post transaction to finalize receiving of %{amount} ツ:' + parse_s1_slatepack_desc: 'To receive %{amount} ツ send this message to the sender:' + parse_s2_slatepack_desc: 'Finalize transaction to send %{amount} ツ:' + parse_s3_slatepack_desc: 'Post transaction to finalize sending of %{amount} ツ:' + resp_slatepack_err: 'An error occurred during creation of the response, check input data or try again:' + resp_exists_err: Such transaction already exists. + resp_canceled_err: Such transaction was already canceled. + create_request_desc: 'Create request to send or receive the funds:' + send_request_desc: 'You have created a request to send %{amount} ツ. Send this message to the receiver:' + send_slatepack_err: An error occurred during creation of request to send funds, check input data or try again. + invoice_desc: 'You have created request to receive %{amount} ツ. Send this message to the sender:' + invoice_slatepack_err: An error occurred during issuing of the invoice, check input data or try again. + finalize_slatepack_err: 'An error occurred during finalization, check input data or try again:' + finalize: Finalize + use_dandelion: Use Dandelion + enter_amount_send: 'You have %{amount} ツ. Enter amount to send:' + enter_amount_receive: 'Enter amount to receive:' + recovery: Recovery + repair_wallet: Repair wallet + repair_desc: Check a wallet, repairing and restoring missing outputs if required. This operation will take time. + repair_unavailable: You need an active connection to the node and completed wallet synchronization. + delete: Delete wallet + delete_conf: Are you sure you want to delete the wallet? + delete_desc: Make sure you have saved your recovery phrase to access funds later. + wallet_loading_err: 'An error occurred during synchronization of the wallet, you can retry or change connection settings by selecting %{settings} at the bottom of the screen.' + wallet: Wallet + send: Send + receive: Receive + settings: Wallet settings + tx_send_cancel_conf: 'Are you sure you want to cancel sending of %{amount} ツ?' + tx_receive_cancel_conf: 'Are you sure you want to cancel receiving of %{amount} ツ?' + rec_phrase_not_found: Recovery phrase not found. + restore_wallet_desc: Restore wallet by deleting all files if usual repair not helped, you will need to re-open your wallet. + fee_base_desc: 'Fee (base value%{value}):' + payment_proof: Payment proof + payment_proof_desc: 'Enter received payment proof to verify transaction:' + payment_proof_valid: 'Entered payment proof is valid:' + payment_proof_error: 'Entered payment proof is not valid:' + tx_delete_confirmation: Are you sure you want to delete the transaction from history? +transport: + desc: 'Use transport to receive or send messages synchronously:' + tor_network: Tor network + connected: Connected + connecting: Connecting + disconnecting: Disconnecting + conn_error: Connection error + disconnected: Disconnected + receiver_address: 'Address of the receiver:' + incorrect_addr_err: 'Entered address is incorrect:' + tor_send_error: An error occurred during sending over Tor, make sure receiver is online, transaction was canceled. + tor_autorun_desc: Whether to launch Tor service on wallet opening to receive transactions synchronously. + tor_sending: Sending over Tor + tor_settings: Tor Settings + bridges: Bridges + bridges_desc: Setup bridges to bypass Tor network censorship if usual connection is not working. + bin_file: 'Binary file:' + conn_line: 'Connection line:' + bridges_disabled: Bridges disabled + bridge_name: 'Bridge %{b}' +network: + self: Network + type: 'Network type:' + mainnet: Main + testnet: Test + connections: Connections + node: Integrated node + metrics: Metrics + mining: Mining + settings: Node settings + enable_node: Enable node + autorun: Autorun + disabled_server: 'Enable integrated node or add another connection method by pressing %{dots} in the top-left corner of the screen.' + no_ips: There are no available IP addresses on your system, server cannot be started, check your network connectivity. + available: Available + not_available: Not available + availability_check: Availability check + android_warning: Attention to Android users. To synchronize integrated node successfully, you must allow access to notifications and remove battery usage restrictions for the Goblin application at system settings of your phone. This is necessary operation for correct work of application in the background. +sync_status: + node_restarting: Node is restarting + node_down: Node is down + initial: Node is starting + no_sync: Node is running + awaiting_peers: Waiting for peers + header_sync: Downloading headers + header_sync_percent: 'Downloading headers: %{percent}%' + tx_hashset_pibd: Downloading state (PIBD) + tx_hashset_pibd_percent: 'Downloading state (PIBD): %{percent}%' + tx_hashset_download: Downloading state + tx_hashset_download_percent: 'Downloading state: %{percent}%' + tx_hashset_setup_history: 'Preparing state (history): %{percent}%' + tx_hashset_setup_position: 'Preparing state (position): %{percent}%' + tx_hashset_setup: Preparing state + tx_hashset_range_proofs_validation: 'Validating state (range proofs): %{percent}%' + tx_hashset_kernels_validation: 'Validating state (kernels): %{percent}%' + tx_hashset_save: Finalizing chain state + body_sync: Downloading blocks + body_sync_percent: 'Downloading blocks: %{percent}%' + shutdown: Node is shutting down +network_node: + header: Header + block: Block + hash: Hash + height: Height + difficulty: Difficulty + time: Time + main_pool: Main pool + stem_pool: Stem pool + data: Data + size: Size (GB) + peers: Peers + error_clean: Node data got corrupted, resync required. + resync: Resync + error_p2p_api: 'An error occurred during %{p2p_api} server initialization, check %{p2p_api} settings by selecting %{settings} at the bottom of the screen.' + error_config: 'An error occurred during configuration initialization, check settings by selecting %{settings} at the bottom of the screen.' + error_unknown: 'An error occurred during initialization, check integrated node settings by selecting %{settings} at the bottom of the screen or resync.' +network_metrics: + loading: Metrics will be available after the synchronization + emission: Emission + inflation: Inflation + supply: Supply + block_time: Block time + reward: Reward + difficulty_window: 'Difficulty window %{size}' +network_mining: + loading: Mining will be available after the synchronization + info: 'Mining server is enabled, you can change its settings by selecting %{settings} at the bottom of the screen. Data is updating when devices are connected.' + restart_server_required: Server restart is required to apply changes. + rewards_wallet: Wallet for rewards + server: Stratum server + address: Address + miners: Miners + devices: Devices + blocks_found: Blocks found + hashrate: 'Hashrate (C%{bits})' + connected: Connected + disconnected: Disconnected +network_settings: + change_value: Change value + stratum_ip: 'Stratum IP address:' + stratum_port: 'Stratum port:' + port_unavailable: Specified port is unavailable + restart_node_required: Node restart is required to apply changes. + choose_wallet: Choose wallet + stratum_wallet_warning: Wallet must be opened to receive rewards. + enable: Enable + disable: Disable + restart: Restart + server: Server + api_ip: 'API IP address:' + api_port: 'API port:' + api_secret: 'Rest API and V2 Owner API token:' + foreign_api_secret: 'Foreign API token:' + disabled: Disabled + enabled: Enabled + ftl: 'The Future Time Limit (FTL):' + ftl_description: Limit on how far into the future, relative to a node's local time in seconds, the timestamp on a new block can be, in order for the block to be accepted. + not_valid_value: Entered value is not valid + full_validation: Full validation + full_validation_description: Whether to run a full chain validation when processing each block (except during synchronization). + archive_mode: Archive mode + archive_mode_desc: Run the node in full archive mode (more disk space and time will be required for synchronization). + attempt_time: 'Mining attempt time (in seconds):' + attempt_time_desc: The amount of time to attempt to mine on a particular header before stopping and re-collecting transactions from the pool + min_share_diff: 'The minimum acceptable share difficulty:' + reset_settings_desc: Reset node settings to default values + reset_settings: Reset settings + reset: Reset + tx_pool: Transaction pool + pool_fee: 'Base fee that is accepted into the pool:' + reorg_period: 'Reorg cache retention period (in minutes):' + max_tx_pool: 'Maximum number of transactions in the pool:' + max_tx_stempool: 'Maximum number of transactions in the stem-pool:' + max_tx_weight: 'Maximum total weight of transactions that can get selected to build a block:' + epoch_duration: 'Epoch duration (in seconds):' + embargo_timer: 'Embargo timer (in seconds):' + aggregation_period: 'Aggregation period (in seconds):' + stem_probability: 'Stem phase probability:' + stem_txs: Stem transactions + p2p_server: P2P server + p2p_port: 'P2P port:' + add_seed: Add DNS Seed + seed_address: 'DNS Seed address:' + add_peer: Add peer + peer_address: 'Peer address:' + peer_address_error: 'Enter IP address or DNS name (make sure specified host is available) in correct format, e.g.: 192.168.0.1:1234 or example.com:5678' + default: Default + allow_list: Allow list + allow_list_desc: Connect only to peers in this list. + deny_list: Deny list + deny_list_desc: Never connect to peers in this list. + favourites: Favourites + favourites_desc: A list of preferred peers to connect to. + ban_window: 'How much time (in seconds) a banned peer should stay banned:' + ban_window_desc: The decision to ban is made by node, based on the correctness of the data received from the peer. + max_inbound_count: 'Maximum number of inbound peer connections:' + max_outbound_count: 'Maximum number of outbound peer connections:' + reset_data_desc: Reset the node data. Use it with a caution only if there are problems with synchronization. + reset_data: Reset data +modal: + cancel: Cancel + save: Save + add: Add +modal_exit: + description: Are you sure you want to quit the application? + exit: Exit +app_settings: + proxy: Proxy + proxy_desc: Whether to use proxy for network requests from the application. +keyboard: + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 5 + 6: 6 + 7: 7 + 8: 8 + 9: 9 + 0: 0 + 01: '-' + q: q + w: w + e: e + r: r + t: t + y: y + u: u + i: i + o: o + p: p + p1: '"' + a: a + s: s + d: d + f: f + g: g + h: h + j: j + k: k + l: l + l1: \ + l2: ':' + z: z + x: x + c: c + v: v + b: b + n: n + m: m + m1: ',' + m2: . + m3: / +goblin: + home: + anonymous: "Anonymous" + connected_nym: "Connected over Nym" + nym_ready: "Nym ready · relays…" + connecting_nym: "Connecting to Nym…" + cant_reach_node: "Can't reach node" + node_synced: "Node synced" + syncing: "Syncing…" + block: "Block %{height}" + waiting_for_chain: "Waiting for chain…" + nav_wallet: "Wallet" + nav_pay: "Pay" + nav_activity: "Activity" + nav_receive: "Receive" + nav_settings: "Settings" + activity: "Activity" + empty_title: "No activity yet" + empty_sub: "Send or receive grin to get started." + recent: "Recent" + scan_to_pay: "Scan to pay" + type_amount: "Type an amount" + request: "Request" + pay: "Pay" + enter_amount: "Enter an amount to pay or request" + activity: + canceled: "canceled" + pending: "pending" + earlier: "Earlier" + today: "Today" + yesterday: "Yesterday" + title: "Activity" + requests: "Requests" + empty_title: "No activity yet" + empty_sub: "Your payments will appear here." + pending_header: "Pending" + receipt: + title: "Receipt" + not_found: "Transaction not found" + for_note: "For %{note}" + details: "Transaction details" + canceled: "Canceled" + expired: "Expired" + funds_returned: "Funds returned" + complete: "Complete" + payment_received: "Payment received" + payment_sent: "Payment sent successfully" + pending: "Pending" + confs: "%{c}/%{r} confirmations" + waiting_to_confirm: "Waiting to confirm" + paying: "Paying…" + you: "You" + to: "To" + from: "From" + nostr: "nostr" + fee_none: "None" + network_fee: "Network fee" + privacy: "Privacy" + privacy_value: "Mimblewimble + Nym" + transaction: "Transaction" + cancel_request: "Cancel request" + cancel_send: "Cancel payment" + cancel_send_confirm: "Tap again to cancel — they may still receive it" + cancel_send_done: "Payment cancelled — your funds are available again" + cancel_send_too_late: "This payment already went through and can't be cancelled" + waiting_to_receive: "Waiting for %{name} to receive…" + request: + title: "%{name} requests" + approve: "Approve" + decline: "Decline" + review_title: "Review request" + hold_to_accept: "Hold to accept" + hold_accept_hint: "Press and hold to pay this request" + receive: + title: "Receive" + requesting: "Requesting %{amt}%{tsu} — share to get paid" + clear_request: "Clear request" + share_handle: "Share your handle to get paid" + copied: "Copied" + copy_nostr_id: "Copy nostr ID" + copy_address: "Copy address" + copy_npub: "Copy npub" + share_message: "Pay me on Goblin (goblin.st) — %{npub}" + privacy_note: "Your username is public. Payment contents stay encrypted over the network." + profile: + title: "Profile" + activity: "Activity" + no_activity: "No activity with them yet." + unblock: "Unblock" + block: "Block" + blocked_blurb: "Blocked — their payments and requests are dropped." + block_blurb: "Blocking drops their incoming payments and requests." + settings: + title: "Settings" + connected_nostr: "Connected to nostr" + connecting_relays: "Connecting to relays…" + identity: "Identity" + copy_npub: "Copy npub (public)" + rotate_key: "Rotate nostr key" + import_identity: "Import identity (.backup / nsec)" + backup_note: "Moving devices? Back up BOTH: your seed phrase (funds) and your identity .backup file (name + key)." + wallet: "Wallet" + display_unit: "Display unit" + relays: "Relays" + node: "Node" + slatepacks: "Slatepacks" + slatepacks_value: "Manual transaction" + lock_wallet: "Lock wallet" + switch_wallet: "Switch wallet" + advanced: "Advanced" + privacy: "Privacy" + mixnet_routing: "Mixnet routing" + messages_lookups: "Messages & lookups" + auto_accept: "Auto-accept" + pairing: "Pairing" + accept_anyone: "Anyone" + accept_contacts: "Contacts only" + accept_ask: "Always ask" + requests: "Requests" + incoming_requests: "Incoming requests" + incoming_requests_sub: "Let others request money from you" + appearance: "Appearance" + theme: "Theme" + theme_light: "Light" + theme_dark: "Dark" + theme_yellow: "Yellow" + archive: "Archive" + export_archive: "Export archive" + wipe_history: "Wipe payment history" + about: "About" + goblin: "Goblin" + build: "Build %{build}" + network: "Network" + network_value: "MW + Nym mixnet + nostr" + third_party: "Third party" + grim: "GRIM (upstream wallet)" + grin_node: "Grin node" + sp_intro: "Advanced — exchange raw slatepacks by hand, the way GRIM does. Use this only when you can't pay or get paid through a username." + sp_receive_group: "Receive or finalize" + sp_receive_blurb: "Paste a slatepack someone gave you. Goblin receives the payment, pays the invoice, or finalizes and posts it." + sp_process: "Process slatepack" + sp_paste_first: "Paste a slatepack first." + sp_reply_ready: "Reply ready — send it back to the sender." + sp_finalizing: "Finalizing and posting to the chain…" + sp_create_group: "Create a payment" + sp_create_blurb: "Make a slatepack to hand to someone. They receive it, send the reply back, and you finalize it above." + sp_amount_hint: "Amount in grin" + sp_addr_hint: "Recipient address (optional)" + sp_create: "Create slatepack" + sp_ready: "Slatepack ready — hand it to the recipient." + sp_amount_gt_zero: "Enter an amount greater than zero." + sp_to_send: "Slatepack to send" + sp_copy: "Copy slatepack" + rotate_line1: "• You get a brand-new RANDOM key; the old npub stops receiving. There is no derivation chain between them." + rotate_line2: "• The new key is NOT recoverable from your seed — back up the new nsec right after rotating." + rotate_line3: "• Your username is RELEASED — claim the same or a new name right after (anyone else can grab it too once it's free)." + rotate_line4: "• Payments still in flight to the old key WILL be disrupted — wait for pending payments to finish first." + rotate_line5: "• Contacts who saved your npub directly must re-find you — share your new npub or re-claimed username." + cancel: "Cancel" + continue: "Continue" + final_confirmation: "Final confirmation" + rotate_confirm_blurb: "This cannot be undone from the app. Type RESET and enter your wallet password to rotate." + type_reset: "Type RESET" + wallet_password: "Wallet password" + rotate_key_btn: "Rotate key" + rotating_key: "Rotating key…" + key_rotated: "Key rotated" + new_npub: "New npub: %{npub}" + backup_new_key: "Back up the NEW secret key now — your seed cannot recover it." + copy_new_nsec: "Copy new nsec backup" + done: "Done" + rotation_failed: "Rotation failed" + close: "Close" + import_identity_title: "Import identity" + import_blurb: "Replaces this wallet's nostr identity — choose a GOBLIN .backup file, or paste a bare nsec. A backup also restores your username and history. Back up the current key first if you still need it." + import_nsec_hint: "nsec1… or pasted backup" + backup_password_hint: "Backup password (only if exported elsewhere)" + import_btn: "Import" + importing: "Importing…" + identity_replaced: "Identity replaced" + now_using: "Now using: %{npub}" + import_failed: "Import failed" + name_authority: "Name authority" + name_authority_title: "Change name authority" + name_authority_blurb: "The server that registers and verifies names. Point it at another instance to use and pay names hosted there." + name_authority_invalid: "Enter a full URL (https://…)." + reset: "Reset" + save: "Save" + backup_file: "Back up to a file" + choose_backup_file: "Choose a .backup file" + backup_read_failed: "Couldn't read that file." + backup_saved: "Backup saved" + backup_saved_sub: "Keep the .backup file safe — anyone with it AND your password can restore your identity." + backup_file_title: "Back up identity" + backup_file_blurb: "Creates one encrypted .backup file with your username and key. Enter your wallet password to seal it." + backup_write_failed: "Couldn't save the file." + create_backup: "Create backup" + registered: "Registered %{name}" + released_msg: "Released — the name is up for grabs" + release_confirm: "Release %{name}?" + release_blurb: "It's up for grabs the moment it's free — anyone can claim it, including the next key you rotate to. You won't be able to register another username for 10 minutes." + releasing: "Releasing…" + keep_it: "Keep it" + release_it: "Release it" + username: "Username" + username_note: "Shown as you. Public on goblin.st. Payments stay encrypted." + release_username: "Release username" + pick_username: "Pick a username — optional" + working: "Working…" + claim: "Claim" + err_just_taken: "That username was just taken" + err_cooldown: "You recently released a username — you can register a new one within 10 minutes." + err_unreachable: "Couldn't reach goblin.st — connection hiccup. Try again." + err_release: "Couldn't release: %{err}" + avail_available: "Available!" + avail_taken: "Taken" + avail_reserved: "Reserved" + avail_invalid: "Names are 3–20 chars: a–z, 0–9, _ or -" + avail_quarantined: "Not available" + avail_unknown: "Couldn't check — connection hiccup. Try again." + advanced: + title: "Advanced" + intro: "Low-level wallet tools from GRIM. You won't normally need these." + own_node_desc: "Sync a full Grin node on this device instead of trusting a public one." + own_node_active: "Running your own node" + repair: "Repair wallet" + repair_desc: "Re-scan the chain and restore any missing outputs. This can take a while." + repair_unavailable: "Needs a synced node connection first." + repairing: "Repairing… %{pct}%" + restore: "Restore wallet" + restore_desc: "Delete local data and rebuild from your seed. Use this if a repair didn't help — you'll re-open the wallet after." + restore_confirm: "Tap again to restore" + show_phrase: "Recovery phrase" + phrase_desc: "Your 24 grin seed words — the only way to recover funds. Keep them offline and private." + reveal: "Show phrase" + hide: "Hide" + password: "Wallet password" + wrong_password: "Wrong password." + delete: "Delete wallet" + delete_desc: "Permanently remove this wallet from this device. Without your seed, funds can't be recovered." + delete_confirm: "Tap again to delete" + manage_node: "Manage node connection" + repair_confirm: "Yes, repair now" + repair_confirm_note: "Repair re-scans the chain and can take a few minutes." + restore_confirm_note: "This erases local data and rebuilds it from your seed — it can take several minutes." + privacy: + title: "Network privacy" + intro: "Goblin sends its private traffic through the Nym mixnet — a five-hop network that hides who is talking to whom, so a relay can't link a payment back to you." + payments: "Payments" + payments_blurb: "Every nostr message carrying a slatepack." + usernames: "usernames" + usernames_blurb: "NIP-05 name lookups to and from goblin.st." + price_avatars: "Price" + price_avatars_blurb: "The live fiat rate shown next to amounts." + over_mixnet: "Over the mixnet" + direct_connection: "Direct connection" + grin_node: "Grin node" + grin_node_blurb: "Block sync and broadcasting your transaction to the network. This is public chain data, the same for everyone, and isn't linked to your identity." + pairing: + title: "Pairing" + intro: "What your balance and amounts are shown against." + pair_with: "Pair with" + rates_note: "Rates fetch over the Nym mixnet, only while a pairing is on — off means no rate request leaves your device." + relays: + title: "Relays" + intro: "Payment messages are mirrored to every relay below; one reachable relay is enough to receive." + your_relays: "Your relays" + add_relay: "Add relay" + add_relay_btn: "Add relay" + save_reconnect: "Save & reconnect" + none: "none" + count: "%{n} relays" + node: + title: "Node" + connection: "Connection" + integrated: "Integrated node" + applies_after: "Applies after the wallet is locked and unlocked again." + add_external: "Add external node" + api_secret_hint: "API secret (optional)" + add_node: "Add node" + integrated_host: "integrated node" + summary_syncing: "%{conn} · syncing" + summary_block: "Block %{height} · %{conn}" + nips: + title: "nostr & NIPs" + intro1: "Goblin speaks nostr — an open protocol of signed messages passed through simple relay servers. Your wallet carries its own nostr identity: a standalone random key, kept deliberately independent of your funds and seed. Every payment travels as an end-to-end encrypted direct message between identities, with the slatepack riding inside." + intro2: "goblin.st is Goblin's name service: claiming a username publishes a name → key mapping there (NIP-05), so people can pay you instead of a long npub. The username is public; payment contents never are. NIPs are the protocol's building blocks — tap one to read the spec." + n05_title: "Names" + n05_blurb: "Maps username@goblin.st to your key, so handles work like addresses." + n17_title: "Private messages" + n17_blurb: "The encrypted DM envelope every payment travels in." + n44_title: "Encryption" + n44_blurb: "The authenticated cipher used inside those messages." + n49_title: "Key encryption" + n49_blurb: "How the secret key is stored at rest, locked by your password." + n59_title: "Gift wrap" + n59_blurb: "Wraps messages so relays can't see who is talking to whom." + n98_title: "HTTP auth" + n98_blurb: "Signs the username registration request to goblin.st." + onboarding: + intro: + private_money_head: "Private money" + private_money_body: "Goblin is a wallet for grin — digital cash with no amounts or addresses on its chain." + send_like_message_head: "Send like a message" + send_like_message_body: "Pay a username or npub and it arrives as an end-to-end encrypted message over nostr and the Nym mixnet — no one in between can see the amount or who's involved." + yours_alone_head: "Yours alone" + yours_alone_body: "Keys, names and history live on this device. Built on the GRIM wallet." + get_started: "Get started" + footnote: "Takes about a minute. You can change everything later." + node: + kicker: "STEP 1 OF 3 · NETWORK" + title: "How should Goblin\nwatch the chain?" + own_title: "Run my own node" + own_badge: "Private" + own_body: "Trusts no one — your wallet checks the chain itself. Syncs in the background while you finish setup." + connect_title: "Connect to a node" + connect_badge: "Instant" + connect_body: "No sync wait. The node you pick can see your wallet's queries." + changeable: "Changeable any time in Settings → Node." + continue: "Continue" + url_invalid: "Node URL must start with http:// or https://" + wallet: + kicker: "STEP 2 OF 3 · WALLET" + title: "Set up your wallet" + create_new: "Create new" + restore_from_seed: "Restore from seed" + name_hint: "Wallet name" + password_hint: "Password" + repeat_password_hint: "Repeat password" + restore_hint: "Have your seed words ready — you'll enter them next." + create_hint: "Next you'll get 24 seed words to write down. They are the money — anyone holding them holds your funds." + continue: "Continue" + passwords_no_match: "Passwords don't match" + words: + kicker: "STEP 2 OF 3 · WALLET" + title_restore: "Enter your seed words" + title_create: "Write these words down" + write_down_hint: "On paper, in order. Anyone with these words can take your funds; without them a lost device means lost funds." + paste: "Paste" + scan_qr: "Scan QR" + copy_clipboard: "Copy to clipboard (avoid this)" + restore_wallet: "Restore wallet" + wrote_them_down: "I wrote them down" + fill_every_word: "Fill every word — tap a word to edit it, or paste the phrase." + confirm: + kicker: "STEP 2 OF 3 · WALLET" + title: "Now prove it" + enter_hint: "Enter the words you just wrote down. Tap a word to type it." + paste: "Paste" + create_wallet: "Create wallet" + keep_going: "Keep going — every word, in order." + identity: + kicker: "STEP 3 OF 3 · IDENTITY" + title: "Your payment identity" + key_being_made: "key being made…" + connected_nym: "connected over Nym" + connecting_nym: "connecting over Nym…" + fresh_key_blurb: "A payment key that isn't part of your seed — rotate it anytime to stay private, without touching your funds." + clean_slate_blurb: "Want a clean slate? Swap in a brand-new key any time — the new you isn't linked to the old one. Same wallet, fresh face." + pick_username: "Pick a username — optional" + username_blurb: "Friends pay your name instead of a long key. Optional — claim one any time." + username_field_hint: "yourname" + working: "Working…" + claim_username: "Claim username" + available_when_connected: "Available once the mixnet connects — or skip and claim later." + youre: "You're %{name}" + claimed_title: "%{name} is yours" + claimed_blurb: "Friends can now pay you by name. You're all set — open your wallet." + open_wallet: "Open my wallet" + skip_for_now: "Skip for now" + import_existing: "Already have a Goblin identity? Import it" + import_title: "Import your identity" + import_blurb: "Paste your nsec or pick a .backup file to keep your existing key and username instead of this new one." + errors: + cant_open: "Couldn't open the wallet: %{err}" + cant_create: "Couldn't create the wallet: %{err}" + send: + scan_to_request: "Scan to request" + scan_to_pay: "Scan to pay" + tab_scan: "Scan" + tab_my_code: "My Code" + request_from: "Request from" + send_to: "Send to" + search_hint: "handle, npub, or name" + suggested: "%{icon} Suggested" + no_contacts: "No contacts yet. Find someone by their handle." + no_profile: "no profile" + tag_contact: "contact" + tag_on_nostr: "on nostr" + searching_nostr: "Searching nostr…" + unverified_title: "Pay an unverified key?" + unverified_body: "No nostr profile is published for this key — it may be brand new, anonymous, or mistyped. Double-check it's the right one before sending." + keep_looking: "Keep looking" + pay_anyway: "Pay anyway" + scan_not_recipient: "That QR isn't a goblin recipient — expected an npub or handle" + scan_prompt: "Position a goblin code in view to activate" + scan_to_pay_me: "Scan to pay me" + share_btn: "%{icon} Share" + share_message: "Pay me on Goblin — %{handle}\n%{link}\nnpub: %{npub}" + none_found: "No one found for %{label}" + enter_recipient: "Enter a handle, npub, or name" + amount_title: "Amount" + to_name: "To %{name}" + not_enough: "You don't have enough grin" + max: "Max" + note_label: "Note" + note_hint: "Add a note…" + add_note: "Add a note" + edit_note: "Edit note" + note_cancel: "Cancel" + note_save: "Save" + review_btn: "Review" + confirm_request: "Confirm request" + review_title: "Review" + requesting_from: "Requesting from %{name}" + youre_sending: "You're sending %{name}" + row_from: "From" + row_to: "To" + row_note: "Note" + row_they_pay: "They pay" + row_they_pay_val: "Only if they approve" + row_delivery: "Delivery" + row_delivery_val: "NIP-44 encrypted, over Nym" + row_network_fee: "Network fee" + row_network_fee_val: "Deducted from your balance" + row_privacy: "Privacy" + row_privacy_val: "Mimblewimble + Nym" + send_request_btn: "Send request" + request_approve_hint: "They'll get a request to approve" + hold_to_send: "Hold to send" + lower_amount: "Go back and lower the amount" + hold_confirm_hint: "Press and hold to confirm" + requesting: "Requesting…" + sending: "Sending…" + they: "They" + request_blocked: "%{who} isn't accepting requests. Ask them to send you grin instead." + failed_request_title: "Couldn't request" + failed_send_title: "Couldn't send" + failed_request_body: "We couldn't deliver the request. Ask them to send you grin instead." + failed_send_body: "The payment wasn't delivered. Your grin is safe — try again." + try_again_btn: "Try again" + close_btn: "Close" + success: + requested: "Requested" + sent: "Sent" + from: "from" + to: "to" + subtitle: "%{dir} %{who} · just now" + done_btn: "Done" + receipt_btn: "Receipt" \ No newline at end of file diff --git a/locales/fr.yml b/locales/fr.yml new file mode 100644 index 00000000..37bc554c --- /dev/null +++ b/locales/fr.yml @@ -0,0 +1,808 @@ +lang_name: Français +copy: Copier +paste: Coller +continue: Continuer +complete: Terminer +error: Erreur +retry: Réessayer +close: Fermer +change: Changer +show: Afficher +delete: Supprimer +clear: Effacer +create: Créer +id: Identifiant +kernel: Noyau +settings: Paramètres +language: Langue +scan: Scanner +qr_code: QR Code +scan_qr: Scanner le QR code +repeat: Répéter +scan_result: Résultat du scan +back: Retour +share: Partager +theme: 'Thème:' +dark: Sombre +light: Clair +file: Fichier +choose_file: Choisir un fichier +choose_folder: Choisir un dossier +crash_report: Rapport d'échec +crash_report_warning: L'application s'est fermée de manière inattendue la dernière fois, vous pouvez partager un rapport d'incident avec les développeurs. +confirmation: Confirmation +enter_url: Entrez l'URL +max_short: MAX +files_location: Emplacement du fichier +moving_files: Déplacer des fichiers +wrong_path_error: Chemin incorrect spécifié +check_updates: Vérifiez les mises à jour au démarrage +update_available: Mise à jour disponible! +changelog: 'Journal des modifications:' +wallets: + await_conf_amount: En attente de confirmation + await_fin_amount: En attente de finalisation + locked_amount: Verrouillé + txs_empty: "Pour recevoir des fonds manuellement ou par transport, utilisez les boutons %{message} ou %{transport} en bas de l'écran. Pour modifier les paramètres du portefeuille, appuyez sur le bouton %{settings}." + title: Goblin + create_desc: Créer ou importer un portefeuille existant à partir de la phrase de récupération sauvegardée. + add: Ajouter un portefeuille + name: 'Nom:' + pass: 'Mot de passe:' + pass_empty: Entrez le mot de passe du portefeuille + current_pass: 'Mot de passe actuel:' + new_pass: 'Nouveau mot de passe:' + min_tx_conf_count: 'Nombre minimum de confirmations pour les transactions:' + recover: Restaurer + recovery_phrase: Phrase de récupération + words_count: 'Nombre de mots:' + enter_word: 'Entrez le mot #%{number}:' + not_valid_word: Mot entré non valide + not_valid_phrase: Phrase entrée non valide + create_phrase_desc: Notez et sauvegardez votre phrase de récupération en toute sécurité. + restore_phrase_desc: Entrez les mots de votre phrase de récupération sauvegardée. + setup_conn_desc: Choisissez comment votre portefeuille se connecte au réseau. + conn_method: Méthode de connexion + ext_conn: 'Connexions externes:' + add_node: Ajouter un noeud + node_url: 'URL du noeud:' + node_secret: 'Secret API (facultatif):' + invalid_url: URL entrée non valide + open: Ouvrir le portefeuille + wrong_pass: Mot de passe entré incorrect + locked: Verrouillé + unlocked: Déverrouillé + enable_node: "Activez le noeud intégré pour utiliser le portefeuille ou changez les paramètres de connexion en sélectionnant %{settings} en bas de l'écran." + node_loading: "Le portefeuille sera chargé après la synchronisation du noeud intégré. Vous pouvez changer la connexion en sélectionnant %{settings} en bas de l'écran." + loading: Chargement + closing: Fermeture + checking: Vérification + default_wallet: Portefeuille par défaut + new_account_desc: 'Entrez le nom du nouveau compte:' + wallet_loading: Chargement du portefeuille + wallet_closing: Fermeture du portefeuille + wallet_checking: Vérification du portefeuille + tx_loading: Chargement des transactions + default_account: Compte par défaut + accounts: Comptes + tx_sent: Envoyé + tx_received: Reçu + tx_sending: Envoi + tx_receiving: Réception + tx_confirming: En attente de confirmation + tx_canceled: Annulé + tx_cancelling: Annulation + tx_finalizing: Finalisation + tx_posting: Publication + tx_confirmed: Confirmé + txs: Transactions + tx: Transaction + messages: Messages + transport: Transport + input_slatepack_desc: 'Entrez le message Slatepack reçu pour créer une réponse ou finaliser la demande:' + parse_slatepack_err: "Une erreur s'est produite lors de la lecture du message, vérifiez l'entrée:" + pay_balance_error: 'Le solde du compte est insuffisant pour payer %{amount} ツ et les frais de réseau.' + parse_i1_slatepack_desc: 'Pour payer %{amount} ツ, envoyez ce message au destinataire:' + parse_i2_slatepack_desc: 'Finalisez la transaction pour recevoir %{amount} ツ:' + parse_i3_slatepack_desc: 'Publiez la transaction pour finaliser la réception de %{amount} ツ:' + parse_s1_slatepack_desc: "Pour recevoir %{amount} ツ, envoyez ce message à l'expéditeur:" + parse_s2_slatepack_desc: 'Finalisez la transaction pour envoyer %{amount} ツ:' + parse_s3_slatepack_desc: "Publiez la transaction pour finaliser l'envoi de %{amount} ツ:" + resp_slatepack_err: "Une erreur s'est produite lors de la création de la réponse, vérifiez les données saisies ou réessayez:" + resp_exists_err: Une telle transaction existe déjà. + resp_canceled_err: Une telle transaction a déjà été annulée. + create_request_desc: 'Créez une demande pour envoyer ou recevoir des fonds:' + send_request_desc: 'Vous avez créé une demande pour envoyer %{amount} ツ. Envoyez ce message au destinataire:' + send_slatepack_err: "Une erreur s'est produite lors de la création de la demande d'envoi de fonds, vérifiez les données saisies ou réessayez." + invoice_desc: "Vous avez créé une demande pour recevoir %{amount} ツ. Envoyez ce message à l'expéditeur:" + invoice_slatepack_err: "Une erreur s'est produite lors de l'émission de la facture, vérifiez les données saisies ou réessayez." + finalize_slatepack_err: "Une erreur s'est produite lors de la finalisation, vérifiez les données saisies ou réessayez:" + finalize: Finaliser + use_dandelion: Utiliser Dandelion + enter_amount_send: 'Vous avez %{amount} ツ. Entrez le montant à envoyer:' + enter_amount_receive: 'Entrez le montant à recevoir:' + recovery: Récupération + repair_wallet: Réparer le portefeuille + repair_desc: Vérifiez un portefeuille, réparez et restaurez les sorties manquantes si nécessaire. Cette opération prendra du temps. + repair_unavailable: "Vous avez besoin d'une connexion active au noeud et d'une synchronisation complète du portefeuille." + delete: Supprimer le portefeuille + delete_conf: Êtes-vous sûr de vouloir supprimer le portefeuille? + delete_desc: "Assurez-vous d'avoir sauvegardé votre phrase de récupération pour accéder aux fonds plus tard." + wallet_loading_err: "Une erreur s'est produite lors de la synchronisation du portefeuille. Vous pouvez réessayer ou changer les paramètres de connexion en sélectionnant %{settings} en bas de l'écran." + wallet: Portefeuille + send: Envoyer + receive: Recevoir + settings: Paramètres du portefeuille + tx_send_cancel_conf: "Êtes-vous sûr de vouloir annuler l'envoi de %{amount} ツ?" + tx_receive_cancel_conf: 'Êtes-vous sûr de vouloir annuler la réception de %{amount} ツ?' + rec_phrase_not_found: Phrase de récupération non trouvée. + restore_wallet_desc: "Restaurer le portefeuille en supprimant tous les fichiers si la réparation habituelle n'a pas aidé. Vous devrez rouvrir votre portefeuille." + fee_base_desc: 'Frais (valeur de base%{value}):' + payment_proof: Preuve de paiement + payment_proof_desc: 'Saisissez la preuve de paiement reçue pour vérifier la transaction:' + payment_proof_valid: 'La preuve de paiement saisie est valide:' + payment_proof_error: "La preuve de paiement saisie n'est pas valide:" + tx_delete_confirmation: Êtes-vous sûr de vouloir supprimer la transaction de l'historique? +transport: + desc: 'Utilisez le transport pour recevoir ou envoyer des messages de manière synchronisée:' + tor_network: Réseau Tor + connected: Connecté + connecting: Connexion en cours + disconnecting: Déconnexion en cours + conn_error: Erreur de connexion + disconnected: Déconnecté + receiver_address: 'Adresse du destinataire:' + incorrect_addr_err: 'Adresse entrée incorrecte:' + tor_send_error: "Une erreur s'est produite lors de l'envoi via Tor. Assurez-vous que le destinataire est en ligne, la transaction a été annulée." + tor_autorun_desc: "Lancer automatiquement le service Tor à l'ouverture du portefeuille pour recevoir les transactions de manière synchronisée." + tor_sending: Envoi via Tor + tor_settings: Paramètres Tor + bridges: Passerelles + bridges_desc: Configurez des passerelles pour contourner la censure du réseau Tor si la connexion habituelle ne fonctionne pas. + bin_file: 'Fichier binaire:' + conn_line: 'Ligne de connexion:' + bridges_disabled: Passerelles désactivés + bridge_name: 'Passerelles %{b}' +network: + self: Réseau + type: 'Type de réseau:' + mainnet: Principal + testnet: Test + connections: Connexions + node: Noeud intégré + metrics: Métriques + mining: Minage + settings: Paramètres du noeud + enable_node: Activer le noeud + autorun: Exécution automatique + disabled_server: "Activez le noeud intégré ou ajoutez une autre méthode de connexion en appuyant sur %{dots} dans le coin supérieur gauche de l'écran." + no_ips: "Il n'y a pas d'adresses IP disponibles sur votre système, le serveur ne peut pas démarrer, vérifiez votre connectivité réseau" + available: Disponible +not_available: Indisponible +availability_check: Vérification de la disponibilité +android_warning: "Attention aux utilisateurs Android. Pour synchroniser correctement le noeud intégré, vous devez autoriser l'accès aux notifications et supprimer les restrictions d'utilisation de la batterie pour l'application Grim dans les paramètres système de votre téléphone. Cette opération est nécessaire pour le bon fonctionnement de l'application en arrière-plan." +sync_status: + node_restarting: Le noeud redémarre + node_down: Le noeud est hors ligne + initial: Le noeud démarre + no_sync: Le noeud fonctionne + awaiting_peers: En attente de pairs + header_sync: Téléchargement des en-têtes + header_sync_percent: 'Téléchargement des en-têtes : %{percent}%' + tx_hashset_pibd: "Téléchargement de l'état (PIBD)" + tx_hashset_pibd_percent: "Téléchargement de l'état (PIBD) : %{percent}%" + tx_hashset_download: "Téléchargement de l'état" + tx_hashset_download_percent: "Téléchargement de l'état : %{percent}%" + tx_hashset_setup_history: "Préparation de l'état (historique) : %{percent}%" + tx_hashset_setup_position: "Préparation de l'état (position) : %{percent}%" + tx_hashset_setup: "Préparation de l'état" + tx_hashset_range_proofs_validation: "Validation de l'état (preuves de plage) : %{percent}%" + tx_hashset_kernels_validation: "Validation de l'état (kernels) : %{percent}%" + tx_hashset_save: "Finalisation de l'état de la chaîne" + body_sync: Téléchargement des blocs + body_sync_percent: 'Téléchargement des blocs : %{percent}%' + shutdown: Le noeud se ferme +network_node: + header: En-tête + block: Bloc + hash: Hash + height: Hauteur + difficulty: Difficulté + time: Temps + main_pool: Pool principal + stem_pool: Pool secondaire + data: Données + size: Taille (GB) + peers: Pairs + error_clean: Les données du noeud ont été corrompues, une resynchronisation est nécessaire. + resync: Resynchronisation + error_p2p_api: "Une erreur s'est produite lors de l'initialisation du serveur %{p2p_api}, vérifiez les paramètres %{p2p_api} en sélectionnant %{settings} en bas de l'écran." + error_config: "Une erreur s'est produite lors de l'initialisation de la configuration, vérifiez les paramètres en sélectionnant %{settings} en bas de l'écran." + error_unknown: "Une erreur s'est produite lors de l'initialisation, vérifiez les paramètres du noeud intégré en sélectionnant %{settings} en bas de l'écran ou resynchronisez." +network_metrics: + loading: Les métriques seront disponibles après la synchronisation + emission: Émission + inflation: Inflation + supply: Offre + block_time: Temps de bloc + reward: Récompense + difficulty_window: 'Fenêtre de difficulté %{size}' +network_mining: + loading: Le minage sera disponible après la synchronisation + info: "Le serveur de minage est activé, vous pouvez changer ses paramètres en sélectionnant %{settings} en bas de l'écran. Les données sont mises à jour lorsque les appareils sont connectés." + restart_server_required: Le redémarrage du serveur est nécessaire pour appliquer les modifications. + rewards_wallet: Portefeuille pour les récompenses + server: Serveur Stratum + address: Adresse + miners: Mineurs + devices: Appareils + blocks_found: Blocs trouvés + hashrate: 'Taux de hachage (C%{bits})' + connected: Connecté + disconnected: Déconnecté +network_settings: + change_value: Modifier la valeur + stratum_ip: 'Adresse IP Stratum :' + stratum_port: 'Port Stratum :' + port_unavailable: Le port spécifié est indisponible + restart_node_required: Le redémarrage du noeud est nécessaire pour appliquer les modifications. + choose_wallet: Choisir un portefeuille + stratum_wallet_warning: Le portefeuille doit être ouvert pour recevoir des récompenses. + enable: Activer + disable: Désactiver + restart: Redémarrer + server: Serveur + api_ip: 'Adresse IP API :' + api_port: 'Port API :' + api_secret: 'Jeton API Rest et API Propriétaire V2 :' + foreign_api_secret: 'Jeton API étranger :' + disabled: Désactivé + enabled: Activé + ftl: 'La Limite de Temps Futur (FTL) :' + ftl_description: "Limite de combien de temps dans le futur, par rapport à l'heure locale d'un noeud en secondes, l'horodatage sur un nouveau bloc peut être, afin que le bloc soit accepté." + not_valid_value: "La valeur entrée n'est pas valide" + full_validation: Validation complète + full_validation_description: Exécuter une validation complète de la chaîne lors du traitement de chaque bloc (sauf pendant la synchronisation). + archive_mode: Mode archive + archive_mode_desc: "Exécuter le noeud en mode archive complet (plus d'espace disque et de temps seront nécessaires pour la synchronisation)." + attempt_time: 'Temps de tentative de minage (en secondes) :' + attempt_time_desc: "Le temps pendant lequel tenter de miner sur un en-tête particulier avant d'arrêter et de récupérer à nouveau les transactions du pool" + min_share_diff: 'La difficulté minimale acceptable du partage :' + reset_settings_desc: Réinitialiser les paramètres du noeud aux valeurs par défaut + reset_settings: Réinitialiser les paramètres + reset: Réinitialiser + tx_pool: Pool de transactions + pool_fee: 'Frais de base acceptés dans le pool :' + reorg_period: 'Période de rétention du cache de réorganisation (en minutes) :' + max_tx_pool: 'Nombre maximum de transactions dans le pool :' + max_tx_stempool: 'Nombre maximum de transactions dans le pool secondaire :' + max_tx_weight: 'Poids total maximum des transactions pouvant être sélectionnées pour créer un bloc :' + epoch_duration: "Durée de l'époque (en secondes) :" + embargo_timer: "Minuterie d'embargo (en secondes) :" + aggregation_period: "Période d'agrégation (en secondes) :" + stem_probability: 'Probabilité de la phase secondaire :' + stem_txs: Transactions secondaires + p2p_server: Serveur P2P + p2p_port: 'Port P2P :' + add_seed: Ajouter une seed DNS + seed_address: 'Adresse de la seed DNS :' + add_peer: Ajouter un pair + peer_address: 'Adresse du pair :' + peer_address_error: "Entrez l'adresse IP ou le nom DNS (assurez-vous que l'hôte spécifié est disponible) au format correct, par exemple : 192.168.0.1:1234 ou example.com:5678" + default: Par défaut + allow_list: Liste autorisée + allow_list_desc: Se connecter uniquement aux pairs de cette liste. + deny_list: Liste refusée + deny_list_desc: Ne jamais se connecter aux pairs de cette liste. + favourites: Favoris + favourites_desc: Une liste de pairs préférés à connecter. + ban_window: 'Combien de temps (en secondes) un pair banni doit rester banni :' + ban_window_desc: La décision de bannir est prise par le noeud, en fonction de la validité des données reçues du pair. + max_inbound_count: 'Nombre maximum de connexions de pairs entrants :' + max_outbound_count: 'Nombre maximum de connexions de pairs sortants :' + reset_data_desc: Réinitialisez les données du noeud. Utilisez-le avec prudence uniquement en cas de problème de synchronisation. + reset_data: Réinitialisation des données +modal: + cancel: Annuler + save: Sauvegarder + add: Ajouter +modal_exit: + description: "Êtes-vous sûr de vouloir quitter l'application ?" + exit: Quitter +app_settings: + proxy: Proxy + proxy_desc: Vaut-il la peine d'utiliser un proxy pour les requêtes réseau de l'application. +keyboard: + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 5 + 6: 6 + 7: 7 + 8: 8 + 9: 9 + 0: 0 + 01: '`' + q: a + w: z + e: e + r: r + t: t + y: y + u: u + i: i + o: o + p: p + p1: ç + a: q + s: s + d: d + f: f + g: g + h: h + j: j + k: k + l: l + l1: m + l2: ù + z: w + x: x + c: c + v: v + b: b + n: n + m: ',' + m1: . + m2: ':' + m3: / +goblin: + home: + anonymous: "Anonyme" + connected_nym: "Connecté via Nym" + nym_ready: "Nym prêt · relais…" + connecting_nym: "Connexion à Nym…" + cant_reach_node: "Nœud injoignable" + node_synced: "Nœud synchronisé" + syncing: "Synchronisation…" + block: "Bloc %{height}" + waiting_for_chain: "En attente de la chaîne…" + nav_wallet: "Portefeuille" + nav_pay: "Payer" + nav_activity: "Activité" + nav_receive: "Recevoir" + nav_settings: "Réglages" + activity: "Activité" + empty_title: "Aucune activité" + empty_sub: "Envoyez ou recevez des grin pour commencer." + recent: "Récent" + scan_to_pay: "Scanner pour payer" + type_amount: "Saisir un montant" + request: "Demander" + pay: "Payer" + enter_amount: "Saisissez un montant à payer ou demander" + activity: + canceled: "annulé" + pending: "en attente" + earlier: "Plus tôt" + today: "Aujourd'hui" + yesterday: "Hier" + title: "Activité" + requests: "Demandes" + empty_title: "Aucune activité" + empty_sub: "Vos paiements apparaîtront ici." + pending_header: "En attente" + receipt: + title: "Reçu" + not_found: "Transaction introuvable" + for_note: "Pour %{note}" + details: "Détails de la transaction" + canceled: "Annulé" + expired: "Expiré" + funds_returned: "Fonds retournés" + complete: "Terminé" + payment_received: "Paiement reçu" + payment_sent: "Paiement envoyé avec succès" + pending: "En attente" + confs: "%{c}/%{r} confirmations" + waiting_to_confirm: "En attente de confirmation" + paying: "Paiement…" + you: "Vous" + to: "À" + from: "De" + nostr: "nostr" + fee_none: "Aucun" + network_fee: "Frais de réseau" + privacy: "Confidentialité" + privacy_value: "Mimblewimble + Nym" + transaction: "Transaction" + cancel_request: "Annuler la demande" + cancel_send: "Annuler le paiement" + cancel_send_confirm: "Appuyez à nouveau pour annuler — il peut encore le recevoir" + cancel_send_done: "Paiement annulé — vos fonds sont à nouveau disponibles" + cancel_send_too_late: "Ce paiement est déjà passé et ne peut pas être annulé" + waiting_to_receive: "En attente de réception par %{name}…" + request: + title: "%{name} demande" + approve: "Approuver" + decline: "Refuser" + review_title: "Vérifier la demande" + hold_to_accept: "Maintenir pour accepter" + hold_accept_hint: "Maintenez pour payer cette demande" + receive: + title: "Recevoir" + requesting: "Demande de %{amt}%{tsu} — partagez pour être payé" + clear_request: "Effacer la demande" + share_handle: "Partagez votre identifiant pour être payé" + copied: "Copié" + copy_nostr_id: "Copier l'ID nostr" + copy_address: "Copier l'adresse" + copy_npub: "Copier npub" + share_message: "Payez-moi sur Goblin (goblin.st) — %{npub}" + privacy_note: "Votre nom d'utilisateur est public. Le contenu des paiements reste chiffré sur le réseau." + profile: + title: "Profil" + activity: "Activité" + no_activity: "Aucune activité avec cette personne." + unblock: "Débloquer" + block: "Bloquer" + blocked_blurb: "Bloqué — ses paiements et demandes sont ignorés." + block_blurb: "Le blocage ignore ses paiements et demandes entrants." + settings: + title: "Réglages" + connected_nostr: "Connecté à nostr" + connecting_relays: "Connexion aux relais…" + identity: "Identité" + copy_npub: "Copier le npub (public)" + rotate_key: "Renouveler la clé nostr" + import_identity: "Importer l'identité (.backup / nsec)" + backup_note: "Changement d'appareil ? Sauvegardez les DEUX : votre phrase seed (fonds) et votre fichier .backup d'identité (nom + clé)." + wallet: "Portefeuille" + display_unit: "Unité d'affichage" + relays: "Relais" + node: "Nœud" + slatepacks: "Slatepacks" + slatepacks_value: "Transaction manuelle" + lock_wallet: "Verrouiller le portefeuille" + switch_wallet: "Changer de portefeuille" + advanced: "Avancé" + privacy: "Confidentialité" + mixnet_routing: "Routage par mixnet" + messages_lookups: "Messages et recherches" + auto_accept: "Acceptation auto" + pairing: "Appairage" + accept_anyone: "Tout le monde" + accept_contacts: "Contacts seulement" + accept_ask: "Toujours demander" + requests: "Demandes" + incoming_requests: "Demandes entrantes" + incoming_requests_sub: "Laisser les autres vous demander de l'argent" + appearance: "Apparence" + theme: "Thème" + theme_light: "Clair" + theme_dark: "Sombre" + theme_yellow: "Jaune" + archive: "Archive" + export_archive: "Exporter l'archive" + wipe_history: "Effacer l'historique des paiements" + about: "À propos" + goblin: "Goblin" + build: "Build %{build}" + network: "Réseau" + network_value: "MW + mixnet Nym + nostr" + third_party: "Tiers" + grim: "GRIM (portefeuille amont)" + grin_node: "Nœud grin" + sp_intro: "Avancé — échangez des slatepacks bruts à la main, comme le fait GRIM. À utiliser seulement si vous ne pouvez pas payer ou être payé via un username." + sp_receive_group: "Recevoir ou finaliser" + sp_receive_blurb: "Collez un slatepack qu'on vous a donné. Goblin reçoit le paiement, règle la facture, ou le finalise et le publie." + sp_process: "Traiter le slatepack" + sp_paste_first: "Collez d'abord un slatepack." + sp_reply_ready: "Réponse prête — renvoyez-la à l'expéditeur." + sp_finalizing: "Finalisation et publication sur la chaîne…" + sp_create_group: "Créer un paiement" + sp_create_blurb: "Créez un slatepack à remettre à quelqu'un. Il le reçoit, vous renvoie la réponse, et vous le finalisez ci-dessus." + sp_amount_hint: "Montant en grin" + sp_addr_hint: "Adresse du destinataire (facultatif)" + sp_create: "Créer un slatepack" + sp_ready: "Slatepack prêt — remettez-le au destinataire." + sp_amount_gt_zero: "Saisissez un montant supérieur à zéro." + sp_to_send: "Slatepack à envoyer" + sp_copy: "Copier le slatepack" + rotate_line1: "• Vous obtenez une toute nouvelle clé ALÉATOIRE ; l'ancien npub cesse de recevoir. Il n'y a aucune chaîne de dérivation entre eux." + rotate_line2: "• La nouvelle clé n'est PAS récupérable depuis votre phrase de récupération — sauvegardez le nouveau nsec juste après le renouvellement." + rotate_line3: "• Votre nom d'utilisateur est LIBÉRÉ — réclamez le même ou un nouveau juste après (une fois libre, n'importe qui peut le prendre)." + rotate_line4: "• Les paiements encore en cours vers l'ancienne clé SERONT interrompus — attendez d'abord la fin des paiements en attente." + rotate_line5: "• Les contacts qui ont enregistré votre npub directement doivent vous retrouver — partagez votre nouveau npub ou votre username re-réservé." + cancel: "Annuler" + continue: "Continuer" + final_confirmation: "Confirmation finale" + rotate_confirm_blurb: "Cette action est irréversible depuis l'app. Tapez RESET et saisissez le mot de passe du portefeuille pour renouveler." + type_reset: "Tapez RESET" + wallet_password: "Mot de passe du portefeuille" + rotate_key_btn: "Renouveler la clé" + rotating_key: "Renouvellement de la clé…" + key_rotated: "Clé renouvelée" + new_npub: "Nouveau npub : %{npub}" + backup_new_key: "Sauvegardez la NOUVELLE clé secrète maintenant — votre phrase de récupération ne peut pas la restaurer." + copy_new_nsec: "Copier la sauvegarde du nouveau nsec" + done: "Terminé" + rotation_failed: "Échec du renouvellement" + close: "Fermer" + import_identity_title: "Importer une identité" + import_blurb: "Remplace l'identité nostr de ce portefeuille — choisissez un fichier .backup GOBLIN, ou collez un nsec. Une sauvegarde restaure aussi votre nom d'utilisateur et votre historique. Sauvegardez d'abord la clé actuelle si besoin." + import_nsec_hint: "nsec1… ou sauvegarde collée" + backup_password_hint: "Mot de passe de sauvegarde (uniquement si exportée ailleurs)" + import_btn: "Importer" + importing: "Importation…" + identity_replaced: "Identité remplacée" + now_using: "Utilise maintenant : %{npub}" + import_failed: "Échec de l'importation" + name_authority: "Autorité de noms" + name_authority_title: "Changer l'autorité de noms" + name_authority_blurb: "Le serveur qui enregistre et vérifie les noms. Pointez-le vers une autre instance pour utiliser et payer des noms qui y sont hébergés." + name_authority_invalid: "Saisissez une URL complète (https://…)." + reset: "Réinitialiser" + save: "Enregistrer" + backup_file: "Sauvegarder dans un fichier" + choose_backup_file: "Choisir un fichier .backup" + backup_read_failed: "Impossible de lire ce fichier." + backup_saved: "Sauvegarde enregistrée" + backup_saved_sub: "Conservez le fichier .backup en lieu sûr — quiconque l'a AVEC votre mot de passe peut restaurer votre identité." + backup_file_title: "Sauvegarder l'identité" + backup_file_blurb: "Crée un fichier .backup chiffré avec votre nom d'utilisateur et votre clé. Saisissez le mot de passe du portefeuille pour le sceller." + backup_write_failed: "Impossible d'enregistrer le fichier." + create_backup: "Créer la sauvegarde" + registered: "%{name} enregistré" + released_msg: "Libéré — le nom est disponible" + release_confirm: "Libérer %{name} ?" + release_blurb: "Dès qu'il est libre, il est disponible — n'importe qui peut le réclamer, y compris votre prochaine clé. Vous ne pourrez pas enregistrer un autre nom d'utilisateur pendant 10 minutes." + releasing: "Libération…" + keep_it: "Le garder" + release_it: "Le libérer" + username: "Nom d'utilisateur" + username_note: "Affiché comme you. Public sur goblin.st. Les paiements restent chiffrés." + release_username: "Libérer le nom d'utilisateur" + pick_username: "Choisir un nom d'utilisateur — facultatif" + working: "En cours…" + claim: "Réserver" + err_just_taken: "Ce nom d'utilisateur vient d'être pris" + err_cooldown: "Vous avez récemment libéré un nom d'utilisateur — vous pouvez en enregistrer un nouveau dans les 10 minutes." + err_unreachable: "Impossible de joindre goblin.st — souci de connexion. Réessayez." + err_release: "Impossible de libérer : %{err}" + avail_available: "Disponible !" + avail_taken: "Pris" + avail_reserved: "Réservé" + avail_invalid: "Les noms font 3 à 20 caractères : a–z, 0–9, _ ou -" + avail_quarantined: "Indisponible" + avail_unknown: "Vérification impossible — souci de connexion. Réessayez." + advanced: + title: "Avancé" + intro: "Outils de portefeuille bas niveau de GRIM. Vous n'en aurez normalement pas besoin." + own_node_desc: "Synchronisez un nœud Grin complet sur cet appareil au lieu de faire confiance à un nœud public." + own_node_active: "Votre propre nœud est actif" + repair: "Réparer le portefeuille" + repair_desc: "Re-scanner la chaîne et restaurer les sorties manquantes. Cela peut prendre du temps." + repair_unavailable: "Nécessite d'abord une connexion à un nœud synchronisé." + repairing: "Réparation… %{pct}%" + restore: "Restaurer le portefeuille" + restore_desc: "Supprimer les données locales et reconstruire depuis votre seed. À utiliser si une réparation n'a pas aidé — vous rouvrirez le portefeuille ensuite." + restore_confirm: "Touchez à nouveau pour restaurer" + show_phrase: "Phrase de récupération" + phrase_desc: "Vos 24 mots de seed grin — le seul moyen de récupérer les fonds. Gardez-les hors ligne et privés." + reveal: "Afficher la phrase" + hide: "Masquer" + password: "Mot de passe du portefeuille" + wrong_password: "Mot de passe incorrect." + delete: "Supprimer le portefeuille" + delete_desc: "Supprimer définitivement ce portefeuille de cet appareil. Sans votre seed, les fonds sont irrécupérables." + delete_confirm: "Touchez à nouveau pour supprimer" + manage_node: "Gérer la connexion au nœud" + repair_confirm: "Oui, réparer maintenant" + repair_confirm_note: "La réparation réanalyse la chaîne et peut prendre quelques minutes." + restore_confirm_note: "Cela efface les données locales et les reconstruit depuis votre seed — cela peut prendre plusieurs minutes." + privacy: + title: "Confidentialité réseau" + intro: "Goblin envoie son trafic privé via le mixnet Nym — un réseau à cinq sauts qui masque qui parle à qui, afin qu'un relais ne puisse pas relier un paiement à vous." + payments: "Paiements" + payments_blurb: "Chaque message nostr transportant un slatepack." + usernames: "usernames" + usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st." + price_avatars: "Prix" + price_avatars_blurb: "Le taux en temps réel affiché à côté des montants." + over_mixnet: "Via le mixnet" + direct_connection: "Connexion directe" + grin_node: "Nœud grin" + grin_node_blurb: "Synchronisation des blocs et diffusion de votre transaction sur le réseau. Ce sont des données de chaîne publiques, identiques pour tous, et non liées à votre identité." + pairing: + title: "Appairage" + intro: "Ce à quoi votre solde et vos montants sont comparés." + pair_with: "Apparier avec" + rates_note: "Les cours sont récupérés via le mixnet Nym, uniquement tant qu'un appairage est actif — désactivé, aucune requête de cours ne quitte votre appareil." + relays: + title: "Relais" + intro: "Les messages de paiement sont répliqués sur tous les relais ci-dessous ; un seul relais joignable suffit pour recevoir." + your_relays: "Vos relais" + add_relay: "Ajouter un relais" + add_relay_btn: "Ajouter un relais" + save_reconnect: "Enregistrer et reconnecter" + none: "aucun" + count: "%{n} relais" + node: + title: "Nœud" + connection: "Connexion" + integrated: "Nœud intégré" + applies_after: "S'applique après le verrouillage puis déverrouillage du portefeuille." + add_external: "Ajouter un nœud externe" + api_secret_hint: "Secret API (facultatif)" + add_node: "Ajouter le nœud" + integrated_host: "nœud intégré" + summary_syncing: "%{conn} · synchronisation" + summary_block: "Bloc %{height} · %{conn}" + nips: + title: "nostr et NIP" + intro1: "Goblin parle nostr — un protocole ouvert de messages signés transmis via de simples serveurs relais. Votre portefeuille porte sa propre identité nostr : une clé aléatoire autonome, gardée délibérément indépendante de vos fonds et de votre phrase de récupération. Chaque paiement voyage comme un message direct chiffré de bout en bout entre identités, le slatepack à l'intérieur." + intro2: "goblin.st est le service de noms de Goblin : réserver un nom d'utilisateur y publie une correspondance nom → clé (NIP-05), pour qu'on puisse payer you au lieu d'un long npub. Le nom d'utilisateur est public ; le contenu des paiements ne l'est jamais. Les NIP sont les briques du protocole — touchez-en un pour lire la spécification." + n05_title: "Noms" + n05_blurb: "Associe username@goblin.st à votre clé, pour que les identifiants fonctionnent comme des adresses." + n17_title: "Messages privés" + n17_blurb: "L'enveloppe de DM chiffré dans laquelle voyage chaque paiement." + n44_title: "Chiffrement" + n44_blurb: "Le chiffrement authentifié utilisé à l'intérieur de ces messages." + n49_title: "Chiffrement de clé" + n49_blurb: "Comment la clé secrète est stockée au repos, verrouillée par votre mot de passe." + n59_title: "Emballage cadeau" + n59_blurb: "Enveloppe les messages pour que les relais ne voient pas qui parle à qui." + n98_title: "Auth HTTP" + n98_blurb: "Signe la demande d'enregistrement du nom d'utilisateur auprès de goblin.st." + onboarding: + intro: + private_money_head: "Argent privé" + private_money_body: "Goblin est un portefeuille pour grin — de l'argent numérique sans montants ni adresses sur sa chaîne." + send_like_message_head: "Envoyer comme un message" + send_like_message_body: "Payez un username ou un npub et cela arrive comme un message chiffré de bout en bout via nostr et le mixnet Nym — personne entre les deux ne voit le montant ni les personnes impliquées." + yours_alone_head: "À vous seul" + yours_alone_body: "Clés, noms et historique vivent sur cet appareil. Construit sur le portefeuille GRIM." + get_started: "Commencer" + footnote: "Environ une minute. Vous pourrez tout changer plus tard." + node: + kicker: "ÉTAPE 1 SUR 3 · RÉSEAU" + title: "Comment Goblin doit-il\nsurveiller la chaîne ?" + own_title: "Lancer mon propre nœud" + own_badge: "Privé" + own_body: "Ne fait confiance à personne — votre portefeuille vérifie la chaîne lui-même. Se synchronise en arrière-plan pendant que vous terminez la configuration." + connect_title: "Se connecter à un nœud" + connect_badge: "Instantané" + connect_body: "Aucune attente de synchronisation. Le nœud que vous choisissez peut voir les requêtes de votre portefeuille." + changeable: "Modifiable à tout moment dans Réglages → Nœud." + continue: "Continuer" + url_invalid: "L'URL du nœud doit commencer par http:// ou https://" + wallet: + kicker: "ÉTAPE 2 SUR 3 · PORTEFEUILLE" + title: "Configurez votre portefeuille" + create_new: "Créer un nouveau" + restore_from_seed: "Restaurer depuis la phrase" + name_hint: "Nom du portefeuille" + password_hint: "Mot de passe" + repeat_password_hint: "Répéter le mot de passe" + restore_hint: "Préparez vos mots de récupération — vous les saisirez ensuite." + create_hint: "Vous obtiendrez ensuite 24 mots de récupération à noter. Ce sont l'argent — quiconque les détient détient vos fonds." + continue: "Continuer" + passwords_no_match: "Les mots de passe ne correspondent pas" + words: + kicker: "ÉTAPE 2 SUR 3 · PORTEFEUILLE" + title_restore: "Saisissez vos mots de récupération" + title_create: "Notez ces mots" + write_down_hint: "Sur papier, dans l'ordre. Quiconque a ces mots peut prendre vos fonds ; sans eux, un appareil perdu signifie des fonds perdus." + paste: "Coller" + scan_qr: "Scanner le QR" + copy_clipboard: "Copier dans le presse-papiers (à éviter)" + restore_wallet: "Restaurer le portefeuille" + wrote_them_down: "Je les ai notés" + fill_every_word: "Remplissez chaque mot — touchez un mot pour le modifier, ou collez la phrase." + confirm: + kicker: "ÉTAPE 2 SUR 3 · PORTEFEUILLE" + title: "Maintenant prouvez-le" + enter_hint: "Saisissez les mots que vous venez de noter. Touchez un mot pour le taper." + paste: "Coller" + create_wallet: "Créer le portefeuille" + keep_going: "Continuez — chaque mot, dans l'ordre." + identity: + kicker: "ÉTAPE 3 SUR 3 · IDENTITÉ" + title: "Votre identité de paiement" + key_being_made: "clé en cours de création…" + connected_nym: "connecté via Nym" + connecting_nym: "connexion via Nym…" + fresh_key_blurb: "Une clé de paiement qui ne fait pas partie de votre seed — renouvelable à tout moment, sans toucher à vos fonds." + clean_slate_blurb: "Envie de repartir à zéro ? Remplacez par une toute nouvelle clé à tout moment — le nouveau vous n'est pas lié à l'ancien. Même portefeuille, nouveau visage." + pick_username: "Choisir un nom d'utilisateur — facultatif" + username_blurb: "Vos amis paient votre nom au lieu d'une longue clé. Facultatif — réclamez-en un à tout moment." + username_field_hint: "votrenom" + working: "En cours…" + claim_username: "Réserver le nom d'utilisateur" + available_when_connected: "Disponible une fois le mixnet connecté — ou passez et réservez plus tard." + youre: "Vous êtes %{name}" + claimed_title: "%{name} est à vous" + claimed_blurb: "Vos amis peuvent désormais vous payer par votre nom. Tout est prêt — ouvrez votre portefeuille." + open_wallet: "Ouvrir mon portefeuille" + skip_for_now: "Passer pour l'instant" + import_existing: "Vous avez déjà une identité Goblin ? Importez-la" + import_title: "Importer votre identité" + import_blurb: "Collez votre nsec ou choisissez un fichier .backup pour conserver votre clé et votre nom existants au lieu de ce nouveau." + errors: + cant_open: "Impossible d'ouvrir le portefeuille : %{err}" + cant_create: "Impossible de créer le portefeuille : %{err}" + send: + scan_to_request: "Scanner pour demander" + scan_to_pay: "Scanner pour payer" + tab_scan: "Scanner" + tab_my_code: "Mon code" + request_from: "Demander à" + send_to: "Envoyer à" + search_hint: "handle, npub ou nom" + suggested: "%{icon} Suggéré" + no_contacts: "Aucun contact pour l'instant. Trouvez quelqu'un par son handle." + no_profile: "pas de profil" + tag_contact: "contact" + tag_on_nostr: "sur nostr" + searching_nostr: "Recherche sur nostr…" + unverified_title: "Payer une clé non vérifiée ?" + unverified_body: "Aucun profil nostr n'est publié pour cette clé — elle peut être toute neuve, anonyme ou mal saisie. Vérifiez bien qu'il s'agit de la bonne avant d'envoyer." + keep_looking: "Continuer à chercher" + pay_anyway: "Payer quand même" + scan_not_recipient: "Ce QR n'est pas un destinataire goblin — un npub ou handle est attendu" + scan_prompt: "Placez un code goblin dans le champ pour activer" + scan_to_pay_me: "Scannez pour me payer" + share_btn: "%{icon} Partager" + share_message: "Payez-moi sur Goblin — %{handle}\n%{link}\nnpub : %{npub}" + none_found: "Personne trouvé pour %{label}" + enter_recipient: "Saisissez un handle, un npub ou un nom" + amount_title: "Montant" + to_name: "À %{name}" + not_enough: "Vous n'avez pas assez de grin" + max: "Max" + note_label: "Note" + note_hint: "Ajouter une note…" + add_note: "Ajouter une note" + edit_note: "Modifier la note" + note_cancel: "Annuler" + note_save: "Enregistrer" + review_btn: "Vérifier" + confirm_request: "Confirmer la demande" + review_title: "Vérification" + requesting_from: "Demande à %{name}" + youre_sending: "Vous envoyez %{name}" + row_from: "De" + row_to: "À" + row_note: "Note" + row_they_pay: "Ils paient" + row_they_pay_val: "Seulement s'ils approuvent" + row_delivery: "Livraison" + row_delivery_val: "Chiffré NIP-44, via Nym" + row_network_fee: "Frais de réseau" + row_network_fee_val: "Déduit de votre solde" + row_privacy: "Confidentialité" + row_privacy_val: "Mimblewimble + Nym" + send_request_btn: "Envoyer la demande" + request_approve_hint: "Ils recevront une demande à approuver" + hold_to_send: "Maintenir pour envoyer" + lower_amount: "Revenez et baissez le montant" + hold_confirm_hint: "Appuyez et maintenez pour confirmer" + requesting: "Demande en cours…" + sending: "Envoi…" + they: "Ils" + request_blocked: "%{who} n'accepte pas les demandes. Demandez-lui de vous envoyer des grin à la place." + failed_request_title: "Échec de la demande" + failed_send_title: "Échec de l'envoi" + failed_request_body: "Impossible de livrer la demande. Demandez-lui de vous envoyer des grin à la place." + failed_send_body: "Le paiement n'a pas été livré. Vos grin sont en sécurité — réessayez." + try_again_btn: "Réessayer" + close_btn: "Fermer" + success: + requested: "Demandé" + sent: "Envoyé" + from: "de" + to: "à" + subtitle: "%{dir} %{who} · à l'instant" + done_btn: "Terminé" + receipt_btn: "Reçu" \ No newline at end of file diff --git a/locales/ru.yml b/locales/ru.yml new file mode 100644 index 00000000..10bbd5ca --- /dev/null +++ b/locales/ru.yml @@ -0,0 +1,808 @@ +lang_name: Русский +copy: Копировать +paste: Вставить +continue: Продолжить +complete: Завершить +error: Ошибка +retry: Повторить +close: Закрыть +change: Изменить +show: Показать +delete: Удалить +clear: Очистить +create: Создать +id: Идентификатор +kernel: Ядро +settings: Настройки +language: Язык +scan: Сканировать +qr_code: QR-код +scan_qr: Сканирование QR-кода +repeat: Повторить +scan_result: Результат сканирования +back: Назад +share: Поделиться +theme: 'Тема:' +dark: Тёмная +light: Светлая +file: Файл +choose_file: Выбрать файл +choose_folder: Выбрать папку +crash_report: Отчёт о сбое +crash_report_warning: В прошлый раз приложение неожиданно закрылось, вы можете поделиться отчетом о сбое с разработчиками. +confirmation: Подтверждение +enter_url: Введите URL-адрес +max_short: МАКС +files_location: Расположение файлов +moving_files: Перемещение файлов +wrong_path_error: Указан неправильный путь +check_updates: Проверять обновления при запуске +update_available: Доступно обновление! +changelog: 'Журнал изменений:' +wallets: + await_conf_amount: Ожидает подтверждения + await_fin_amount: Ожидает завершения + locked_amount: Заблокировано + txs_empty: 'Для получения средств вручную или через транспорт используйте кнопки %{message} или %{transport} внизу экрана, для изменения настроек кошелька нажмите кнопку %{settings}.' + title: Goblin + create_desc: Создайте или импортируйте существующий кошелёк из сохранённой фразы восстановления. + add: Добавить кошелёк + name: 'Название:' + pass: 'Пароль:' + pass_empty: Введите пароль от кошелька + current_pass: 'Текущий пароль:' + new_pass: 'Новый пароль:' + min_tx_conf_count: 'Минимальное количество подтверждений для транзакций:' + recover: Восстановить + recovery_phrase: Фраза восстановления + words_count: 'Количество слов:' + enter_word: 'Введите слово #%{number}:' + not_valid_word: Введено недопустимое слово + not_valid_phrase: Введена недопустимая фраза восстановления + create_phrase_desc: Безопасно запишите и сохраните вашу фразу восстановления. + restore_phrase_desc: Введите слова из вашей сохранённой фразы восстановления. + setup_conn_desc: Выберите способ подключения вашего кошелька к сети. + conn_method: Способ подключения + ext_conn: 'Внешние подключения:' + add_node: Добавить узел + node_url: 'URL узла:' + node_secret: 'API токен (необязательно):' + invalid_url: Введённый URL-адрес недействителен + open: Открыть кошелёк + wrong_pass: Введён неправильный пароль + locked: Заблокирован + unlocked: Разблокирован + enable_node: 'Чтобы использовать кошелёк, включите встроенный узел или измените настройки подключения, выбрав %{settings} внизу экрана.' + node_loading: 'Кошелёк будет загружен после синхронизации встроенного узла, вы можете изменить подключение, выбрав %{settings} внизу экрана.' + loading: Загружается + closing: Закрывается + checking: Проверяется + default_wallet: Стандартный кошелёк + new_account_desc: 'Введите название нового аккаунта:' + wallet_loading: Загрузка кошелька + wallet_closing: Закрытие кошелька + wallet_checking: Проверка кошелька + tx_loading: Загрузка транзакций + default_account: Стандартный аккаунт + accounts: Аккаунты + tx_sent: Отправлено + tx_received: Получено + tx_sending: Отправка + tx_receiving: Получение + tx_confirming: Ожидает подтверждения + tx_canceled: Отменено + tx_cancelling: Отмена + tx_finalizing: Завершение + tx_posting: Публикация + tx_confirmed: Подтверждено + txs: Транзакции + tx: Транзакция + messages: Сообщения + transport: Транспорт + input_slatepack_desc: 'Введите сообщение для создания ответа или завершения запроса:' + parse_slatepack_err: 'Во время чтения сообщения произошла ошибка, проверьте входные данные:' + pay_balance_error: 'Средств на аккаунте недостаточно для оплаты %{amount} ツ и комиссии сети.' + parse_i1_slatepack_desc: 'Для оплаты %{amount} ツ отправьте это сообщение получателю:' + parse_i2_slatepack_desc: 'Завершите транзакцию для получения %{amount} ツ:' + parse_i3_slatepack_desc: 'Опубликуйте транзакцию для завершения получения %{amount} ツ:' + parse_s1_slatepack_desc: 'Для получения %{amount} ツ отправьте это сообщение отправителю:' + parse_s2_slatepack_desc: 'Завершите транзакцию для отправки %{amount} ツ:' + parse_s3_slatepack_desc: 'Опубликуйте транзакцию для завершения отправки %{amount} ツ:' + resp_slatepack_err: 'Во время создания ответа произошла ошибка, проверьте входные данные или повторите попытку:' + resp_exists_err: Такая транзакция уже существует. + resp_canceled_err: Такая транзакция уже была отменена. + create_request_desc: 'Запрос на отправку или получение средств:' + send_request_desc: 'Вы создали запрос на отправку %{amount} ツ. Отправьте это сообщение получателю:' + send_slatepack_err: Во время создания запроса на отправку средств произошла ошибка, проверьте входные данные или повторите попытку. + invoice_desc: 'Вы создали запрос на получение %{amount} ツ. Отправьте это сообщение отправителю:' + invoice_slatepack_err: Во время выставления счёта произошла ошибка, проверьте входные данные или повторите попытку. + finalize_slatepack_err: 'Во время завершения произошла ошибка, проверьте входные данные или повторите попытку:' + finalize: Завершить + use_dandelion: Использовать Dandelion + enter_amount_send: 'У вас есть %{amount} ツ. Введите количество для отправки:' + enter_amount_receive: 'Введите количество для получения:' + recovery: Восстановление + repair_wallet: Исправить кошелёк + repair_desc: Проверить кошелёк, исправляя и восстанавливая недостающие выходы, если это необходимо. Эта операция займёт время. + repair_unavailable: Необходимо активное подключение к узлу и завершённая синхронизация кошелька. + delete: Удалить кошелёк + delete_conf: Вы уверены, что хотите удалить кошелек? + delete_desc: Убедитесь, что вы сохранили вашу фразу восстановления, чтобы получить доступ к средствам. + wallet_loading_err: 'Во время синхронизации кошелька произошла ошибка, вы можете повторить попытку или изменить настройки подключения, выбрав %{settings} внизу экрана.' + wallet: Кошелёк + send: Отправить + receive: Получить + settings: Настройки кошелька + tx_send_cancel_conf: 'Вы действительно хотите отменить отправку %{amount} ツ?' + tx_receive_cancel_conf: 'Вы действительно хотите отменить получение %{amount} ツ?' + rec_phrase_not_found: Фраза восстановления не найдена. + restore_wallet_desc: Восстановить кошелёк, удалив все файлы, если обычное исправление не помогло. Необходимо переоткрыть кошелёк. + fee_base_desc: 'Комиссия (базовое значение%{value}):' + payment_proof: Подтверждение оплаты + payment_proof_desc: 'Введите полученное подтверждение оплаты для проверки транзакции:' + payment_proof_valid: 'Введённое подтверждение оплаты действительно:' + payment_proof_error: 'Введённое подтверждение оплаты недействительно:' + tx_delete_confirmation: Вы уверены, что хотите удалить транзакцию из истории? +transport: + desc: 'Используйте транспорт для синхронных получения или отправки сообщений:' + tor_network: Сеть Tor + connected: Подключено + connecting: Подключение + disconnecting: Отключение + conn_error: Ошибка подключения + disconnected: Отключено + receiver_address: 'Адрес получателя:' + incorrect_addr_err: 'Введённый адрес неверен:' + tor_send_error: Во время отправки через Tor произошла ошибка, убедитесь, что получатель находится онлайн, транзакция была отменена. + tor_autorun_desc: Запускать ли Tor сервис при открытии кошелька для синхронного получения транзакций. + tor_sending: Отправка через Tor + tor_settings: Настройки Tor + bridges: Мосты + bridges_desc: Настройте мосты для обхода цензуры сети Tor, если обычное соединение не работает. + bin_file: 'Исполняемый файл:' + conn_line: 'Строка подключения:' + bridges_disabled: Мосты отключены + bridge_name: 'Мост %{b}' +network: + self: Сеть + type: 'Тип сети:' + mainnet: Основная + testnet: Тестовая + connections: Подключения + node: Встроенный узел + metrics: Показатели + mining: Майнинг + settings: Настройки узла + enable_node: Включить узел + autorun: Автозапуск + disabled_server: 'Включите встроенный узел или добавьте другой способ подключения, нажав %{dots} в левом-верхнем углу экрана.' + no_ips: В вашей системе отсутствуют доступные IP адреса, запуск сервера невозможен, проверьте ваше подключение к сети. + available: Доступно + not_available: Недоступно + availability_check: Проверка доступности + android_warning: Вниманию пользователей Android. Для успешной синхронизации встроенного узла необходимо разрешить доступ к уведомлениям и снять ограничения на использование батареи для приложения Grim в настройках телефона. Это необходимая операция для корректной работы приложения в фоне. +sync_status: + node_restarting: Узел перезапускается + node_down: Узел выключен + initial: Запуск узла + no_sync: Узел запущен + awaiting_peers: Ожидание пиров + header_sync: Загрузка заголовков + header_sync_percent: 'Загрузка заголовков: %{percent}%' + tx_hashset_pibd: Загрузка состояния (PIBD) + tx_hashset_pibd_percent: 'Загрузка состояния (PIBD): %{percent}%' + tx_hashset_download: Загрузка состояния + tx_hashset_download_percent: 'Загрузка состояния: %{percent}%' + tx_hashset_setup_history: 'Подготовка состояния (история): %{percent}%' + tx_hashset_setup_position: 'Подготовка состояния (позиция): %{percent}%' + tx_hashset_setup: Подготовка состояния + tx_hashset_range_proofs_validation: 'Проверка доказательств: %{percent}%' + tx_hashset_kernels_validation: 'Проверка ядер: %{percent}%' + tx_hashset_save: Сохранение состояния цепи + body_sync: Загрузка блоков + body_sync_percent: 'Загрузка блоков: %{percent}%' + shutdown: Выключение узла +network_node: + header: Заголовок + block: Блок + hash: Хэш + height: Высота + difficulty: Сложность + time: Время + main_pool: Основной пул + stem_pool: Stem пул + data: Данные + size: Размер (ГБ) + peers: Пиры + error_clean: Данные узла повреждены, необходима повторная синхронизация. + resync: Cинхронизация + error_p2p_api: 'Во время инициализации %{p2p_api} сервера произошла ошибка, проверьте настройки %{p2p_api}, выбрав %{settings} внизу экрана.' + error_config: 'Во время инициализации конфигурации произошла ошибка, проверьте настройки встроенного узла, выбрав %{settings} внизу экрана.' + error_unknown: 'Во время инициализации произошла ошибка, проверьте настройки встроенного узла, выбрав %{settings} внизу экрана или очистите данные.' +network_metrics: + loading: Показатели будут доступны после синхронизации + emission: Эмиссия + inflation: Инфляция + supply: Предложение + block_time: Время блока + reward: Награда + difficulty_window: 'Окно сложности %{size}' +network_mining: + loading: Майнинг будет доступен после синхронизации + info: 'Сервер майнинга запущен, вы можете изменить его настройки, выбрав %{settings} внизу экрана. Данные обновляются, когда устройства подключены.' + restart_server_required: Для применения изменений потребуется перезапуск Stratum сервера. + rewards_wallet: Кошелёк для наград + server: Stratum сервер + address: Адрес + miners: Майнеры + devices: Устройства + blocks_found: Найдено блоков + hashrate: 'Хешрэйт (C%{bits})' + connected: Подключен + disconnected: Отключен +network_settings: + change_value: Изменить значение + stratum_ip: 'Stratum IP адрес:' + stratum_port: 'Порт Stratum:' + port_unavailable: Указанный порт недоступен + restart_node_required: Для применения изменений требуется перезапуск узла. + choose_wallet: Выбрать кошелёк + stratum_wallet_warning: Кошелёк должен быть открыт для получения наград. + enable: Включить + disable: Выключить + restart: Перезапуск + server: Сервер + api_ip: 'API IP адрес:' + api_port: 'API порт:' + api_secret: 'Rest и V2 Owner API токен:' + foreign_api_secret: 'Foreign API токен:' + disabled: Отключен + enabled: Включен + ftl: 'Предел Будущего Времени (FTL):' + ftl_description: Насколько далеко в будущем, относительно локального времени узла в секундах, может находиться временная метка на новом блоке для его принятия. + not_valid_value: Введено недопустимое значение + full_validation: Полная валидация + full_validation_description: Запускать ли полную проверку цепи при обработке каждого блока (за исключением синхронизации). + archive_mode: Архивный режим + archive_mode_desc: Запустить узел в режиме полного архива (потребуется больше места и времени для синхронизации). + attempt_time: 'Время попытки майнинга (в секундах):' + attempt_time_desc: Количество времени для попытки майнинга на определённом заголовке перед остановкой и повторным сбором транзакций из пула + min_share_diff: 'Минимальная допустимая сложность шары:' + reset_settings_desc: Сбросить настройки узла до стандартных значений + reset_settings: Сброс настроек + reset: Сбросить + tx_pool: Пул транзакций + pool_fee: 'Базовая комиссия, принимаемая в пул:' + reorg_period: 'Срок хранения кэша реорганизации (в минутах):' + max_tx_pool: 'Максимальное количество транзакций в пуле:' + max_tx_stempool: 'Максимальное количество транзакций в stem-пуле:' + max_tx_weight: 'Максимальный общий вес транзакций, которые могут быть выбраны для построения блока:' + epoch_duration: 'Длительность эпохи (в секундах):' + embargo_timer: 'Таймер эмбарго (в секундах):' + aggregation_period: 'Период агрегации (в секундах):' + stem_probability: 'Вероятность фазы Stem:' + stem_txs: Stem транзакций + p2p_server: P2P сервер + p2p_port: 'P2P порт:' + add_seed: Добавить DNS Seed + seed_address: 'Адрес DNS Seed:' + add_peer: Добавить пир + peer_address: 'Адрес пира:' + peer_address_error: 'Введите IP адрес или DNS имя (убедитесь, что указанный хост доступен) в правильном формате, например: 192.168.0.1:1234 или example.com:5678' + default: По умолчанию + allow_list: Белый список + allow_list_desc: Подключаться только к пирам в данном списке. + deny_list: Чёрный список + deny_list_desc: Никогда не подключаться к пирам в данном списке. + favourites: Избранное + favourites_desc: Список предпочтительных пиров для подключения. + ban_window: 'Сколько времени (в секундах) забаненый пир должен оставаться забаненым:' + ban_window_desc: Решение о запрете принимается узлом, основываясь на корректности данных полученных от пира. + max_inbound_count: 'Максимальное количество входящих подключений пиров:' + max_outbound_count: 'Максимальное количество исходящих подключений к пирам:' + reset_data_desc: Сбросить данные узла. Используйте с осторожностью, только при наличии проблем с синхронизацией. + reset_data: Сброс данных +modal: + cancel: Отмена + save: Сохранить + add: Добавить +modal_exit: + description: Вы уверены, что хотите выйти из приложения? + exit: Выход +app_settings: + proxy: Прокси + proxy_desc: Стоит ли использовать прокси для сетевых запросов из приложения. +keyboard: + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 5 + 6: 6 + 7: 7 + 8: 8 + 9: 9 + 0: 0 + 01: ъ + q: й + w: ц + e: у + r: к + t: е + y: н + u: г + i: ш + o: щ + p: з + p1: х + a: ф + s: ы + d: в + f: а + g: п + h: р + j: о + k: л + l: д + l1: ж + l2: э + z: я + x: ч + c: с + v: м + b: и + n: т + m: ь + m1: б + m2: ю + m3: ё +goblin: + home: + anonymous: "Аноним" + connected_nym: "Подключено через Nym" + nym_ready: "Nym готов · реле…" + connecting_nym: "Подключение к Nym…" + cant_reach_node: "Нет связи с узлом" + node_synced: "Узел синхронизирован" + syncing: "Синхронизация…" + block: "Блок %{height}" + waiting_for_chain: "Ожидание цепочки…" + nav_wallet: "Кошелёк" + nav_pay: "Оплатить" + nav_activity: "Действия" + nav_receive: "Получить" + nav_settings: "Настройки" + activity: "Действия" + empty_title: "Пока нет действий" + empty_sub: "Отправьте или получите grin, чтобы начать." + recent: "Недавние" + scan_to_pay: "Сканируйте для оплаты" + type_amount: "Введите сумму" + request: "Запросить" + pay: "Оплатить" + enter_amount: "Введите сумму для оплаты или запроса" + activity: + canceled: "отменено" + pending: "в ожидании" + earlier: "Ранее" + today: "Сегодня" + yesterday: "Вчера" + title: "Действия" + requests: "Запросы" + empty_title: "Пока нет действий" + empty_sub: "Здесь появятся ваши платежи." + pending_header: "В ожидании" + receipt: + title: "Квитанция" + not_found: "Транзакция не найдена" + for_note: "За %{note}" + details: "Детали транзакции" + canceled: "Отменено" + expired: "Истекло" + funds_returned: "Средства возвращены" + complete: "Завершено" + payment_received: "Платёж получен" + payment_sent: "Платёж успешно отправлен" + pending: "В ожидании" + confs: "%{c}/%{r} подтверждений" + waiting_to_confirm: "Ожидание подтверждения" + paying: "Оплата…" + you: "Вы" + to: "Кому" + from: "От" + nostr: "nostr" + fee_none: "Нет" + network_fee: "Сетевая комиссия" + privacy: "Приватность" + privacy_value: "Mimblewimble + Nym" + transaction: "Транзакция" + cancel_request: "Отменить запрос" + cancel_send: "Отменить платёж" + cancel_send_confirm: "Нажмите ещё раз для отмены — он ещё может его получить" + cancel_send_done: "Платёж отменён — ваши средства снова доступны" + cancel_send_too_late: "Этот платёж уже прошёл и не может быть отменён" + waiting_to_receive: "Ожидание, пока %{name} получит…" + request: + title: "%{name} запрашивает" + approve: "Принять" + decline: "Отклонить" + review_title: "Проверить запрос" + hold_to_accept: "Удерживайте, чтобы принять" + hold_accept_hint: "Нажмите и удерживайте, чтобы оплатить запрос" + receive: + title: "Получить" + requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату" + clear_request: "Очистить запрос" + share_handle: "Поделитесь именем, чтобы получить оплату" + copied: "Скопировано" + copy_nostr_id: "Копировать nostr ID" + copy_address: "Копировать адрес" + copy_npub: "Копировать npub" + share_message: "Заплатите мне в Goblin (goblin.st) — %{npub}" + privacy_note: "Ваше имя публично. Содержимое платежей остаётся зашифрованным в сети." + profile: + title: "Профиль" + activity: "Действия" + no_activity: "Пока нет действий с ними." + unblock: "Разблокировать" + block: "Заблокировать" + blocked_blurb: "Заблокирован — их платежи и запросы отклоняются." + block_blurb: "Блокировка отклоняет их входящие платежи и запросы." + settings: + title: "Настройки" + connected_nostr: "Подключено к nostr" + connecting_relays: "Подключение к реле…" + identity: "Личность" + copy_npub: "Копировать npub (публичный)" + rotate_key: "Сменить ключ nostr" + import_identity: "Импорт личности (.backup / nsec)" + backup_note: "Меняете устройство? Сохраните ОБА: seed-фразу (средства) и файл .backup личности (имя + ключ)." + wallet: "Кошелёк" + display_unit: "Единица отображения" + relays: "Реле" + node: "Узел" + slatepacks: "Slatepacks" + slatepacks_value: "Ручная транзакция" + lock_wallet: "Заблокировать кошелёк" + switch_wallet: "Сменить кошелёк" + advanced: "Дополнительно" + privacy: "Приватность" + mixnet_routing: "Маршрутизация через mixnet" + messages_lookups: "Сообщения и поиск" + auto_accept: "Автоприём" + pairing: "Привязка" + accept_anyone: "Любой" + accept_contacts: "Только контакты" + accept_ask: "Всегда спрашивать" + requests: "Запросы" + incoming_requests: "Входящие запросы" + incoming_requests_sub: "Разрешить другим запрашивать у вас деньги" + appearance: "Внешний вид" + theme: "Тема" + theme_light: "Светлая" + theme_dark: "Тёмная" + theme_yellow: "Жёлтая" + archive: "Архив" + export_archive: "Экспорт архива" + wipe_history: "Стереть историю платежей" + about: "О приложении" + goblin: "Goblin" + build: "Сборка %{build}" + network: "Сеть" + network_value: "MW + mixnet Nym + nostr" + third_party: "Сторонние" + grim: "GRIM (исходный кошелёк)" + grin_node: "Узел Grin" + sp_intro: "Для опытных — обмен сырыми slatepacks вручную, как в GRIM. Используйте только если не можете платить или получать через username." + sp_receive_group: "Получить или завершить" + sp_receive_blurb: "Вставьте slatepack, который вам дали. Goblin получит платёж, оплатит счёт или завершит и опубликует его." + sp_process: "Обработать slatepack" + sp_paste_first: "Сначала вставьте slatepack." + sp_reply_ready: "Ответ готов — отправьте его обратно отправителю." + sp_finalizing: "Завершение и публикация в цепочку…" + sp_create_group: "Создать платёж" + sp_create_blurb: "Создайте slatepack для передачи кому-то. Они получат его, отправят ответ, а вы завершите его выше." + sp_amount_hint: "Сумма в grin" + sp_addr_hint: "Адрес получателя (необязательно)" + sp_create: "Создать slatepack" + sp_ready: "Slatepack готов — передайте его получателю." + sp_amount_gt_zero: "Введите сумму больше нуля." + sp_to_send: "Slatepack для отправки" + sp_copy: "Копировать slatepack" + rotate_line1: "• Вы получите совершенно новый СЛУЧАЙНЫЙ ключ; старый npub перестанет принимать. Между ними нет цепочки вывода." + rotate_line2: "• Новый ключ НЕЛЬЗЯ восстановить из seed — сохраните новый nsec сразу после смены." + rotate_line3: "• Ваше имя пользователя ОСВОБОЖДАЕТСЯ — сразу после заявите то же или новое имя (как только свободно, его может занять кто угодно)." + rotate_line4: "• Платежи, всё ещё идущие к старому ключу, БУДУТ нарушены — сначала дождитесь завершения ожидающих платежей." + rotate_line5: "• Контакты, сохранившие ваш npub напрямую, должны найти вас заново — поделитесь новым npub или заново занятым username." + cancel: "Отмена" + continue: "Продолжить" + final_confirmation: "Финальное подтверждение" + rotate_confirm_blurb: "Это нельзя отменить из приложения. Введите RESET и пароль кошелька, чтобы сменить." + type_reset: "Введите RESET" + wallet_password: "Пароль кошелька" + rotate_key_btn: "Сменить ключ" + rotating_key: "Смена ключа…" + key_rotated: "Ключ сменён" + new_npub: "Новый npub: %{npub}" + backup_new_key: "Сохраните НОВЫЙ секретный ключ сейчас — seed не сможет его восстановить." + copy_new_nsec: "Копировать резерв нового nsec" + done: "Готово" + rotation_failed: "Смена не удалась" + close: "Закрыть" + import_identity_title: "Импорт личности" + import_blurb: "Заменяет nostr-личность этого кошелька — выберите файл GOBLIN .backup или вставьте nsec. Резервная копия также восстанавливает имя и историю. Сначала сохраните текущий ключ, если он ещё нужен." + import_nsec_hint: "nsec1… или вставленная копия" + backup_password_hint: "Пароль резерва (только если экспортирован в другом месте)" + import_btn: "Импорт" + importing: "Импорт…" + identity_replaced: "Личность заменена" + now_using: "Сейчас используется: %{npub}" + import_failed: "Импорт не удался" + name_authority: "Сервер имён" + name_authority_title: "Сменить сервер имён" + name_authority_blurb: "Сервер, который регистрирует и проверяет имена. Укажите другой инстанс, чтобы использовать и оплачивать имена оттуда." + name_authority_invalid: "Введите полный URL (https://…)." + reset: "Сброс" + save: "Сохранить" + backup_file: "Сохранить в файл" + choose_backup_file: "Выбрать файл .backup" + backup_read_failed: "Не удалось прочитать файл." + backup_saved: "Резервная копия сохранена" + backup_saved_sub: "Храните файл .backup в безопасности — любой, у кого есть он И ваш пароль, может восстановить вашу личность." + backup_file_title: "Резервная копия личности" + backup_file_blurb: "Создаёт один зашифрованный файл .backup с именем и ключом. Введите пароль кошелька, чтобы запечатать его." + backup_write_failed: "Не удалось сохранить файл." + create_backup: "Создать копию" + registered: "Зарегистрировано %{name}" + released_msg: "Освобождено — имя свободно для занятия" + release_confirm: "Освободить %{name}?" + release_blurb: "Как только оно свободно, его можно занять — кто угодно, включая ваш следующий ключ. Вы не сможете зарегистрировать другое имя в течение 10 минут." + releasing: "Освобождение…" + keep_it: "Оставить" + release_it: "Освободить" + username: "Имя пользователя" + username_note: "Показывается как you. Публично на goblin.st. Платежи остаются зашифрованными." + release_username: "Освободить имя" + pick_username: "Выберите имя — необязательно" + working: "Обработка…" + claim: "Занять" + err_just_taken: "Это имя только что заняли" + err_cooldown: "Вы недавно освободили имя — можно зарегистрировать новое в течение 10 минут." + err_unreachable: "Не удалось связаться с goblin.st — сбой соединения. Попробуйте снова." + err_release: "Не удалось освободить: %{err}" + avail_available: "Доступно!" + avail_taken: "Занято" + avail_reserved: "Зарезервировано" + avail_invalid: "Имена 3–20 символов: a–z, 0–9, _ или -" + avail_quarantined: "Недоступно" + avail_unknown: "Не удалось проверить — сбой соединения. Попробуйте снова." + advanced: + title: "Дополнительно" + intro: "Низкоуровневые инструменты кошелька из GRIM. Обычно они вам не нужны." + own_node_desc: "Синхронизируйте полный узел Grin на этом устройстве вместо доверия публичному." + own_node_active: "Ваш узел запущен" + repair: "Починить кошелёк" + repair_desc: "Повторно просканировать цепочку и восстановить недостающие выходы. Это может занять время." + repair_unavailable: "Сначала нужно синхронизированное подключение к узлу." + repairing: "Починка… %{pct}%" + restore: "Восстановить кошелёк" + restore_desc: "Удалить локальные данные и пересоздать из seed-фразы. Используйте, если починка не помогла — после этого откройте кошелёк заново." + restore_confirm: "Нажмите ещё раз для восстановления" + show_phrase: "Фраза восстановления" + phrase_desc: "Ваши 24 seed-слова grin — единственный способ восстановить средства. Храните их офлайн и в тайне." + reveal: "Показать фразу" + hide: "Скрыть" + password: "Пароль кошелька" + wrong_password: "Неверный пароль." + delete: "Удалить кошелёк" + delete_desc: "Безвозвратно удалить этот кошелёк с этого устройства. Без seed-фразы средства не восстановить." + delete_confirm: "Нажмите ещё раз для удаления" + manage_node: "Управление подключением к узлу" + repair_confirm: "Да, восстановить сейчас" + repair_confirm_note: "Восстановление повторно сканирует цепочку и может занять несколько минут." + restore_confirm_note: "Это стирает локальные данные и восстанавливает их из seed-фразы — может занять несколько минут." + privacy: + title: "Сетевая приватность" + intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами." + payments: "Платежи" + payments_blurb: "Каждое nostr-сообщение, несущее slatepack." + usernames: "usernames" + usernames_blurb: "Поиск имён NIP-05 к и от goblin.st." + price_avatars: "Цена" + price_avatars_blurb: "Текущий курс рядом с суммами." + over_mixnet: "Через mixnet" + direct_connection: "Прямое соединение" + grin_node: "Узел Grin" + grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью." + pairing: + title: "Привязка" + intro: "К чему привязаны отображаемые баланс и суммы." + pair_with: "Привязать к" + rates_note: "Курсы загружаются через mixnet Nym только при включённой привязке — выключено означает, что запрос курса не покидает устройство." + relays: + title: "Реле" + intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле." + your_relays: "Ваши реле" + add_relay: "Добавить реле" + add_relay_btn: "Добавить реле" + save_reconnect: "Сохранить и переподключить" + none: "нет" + count: "%{n} реле" + node: + title: "Узел" + connection: "Соединение" + integrated: "Встроенный узел" + applies_after: "Применяется после блокировки и повторной разблокировки кошелька." + add_external: "Добавить внешний узел" + api_secret_hint: "API-секрет (необязательно)" + add_node: "Добавить узел" + integrated_host: "встроенный узел" + summary_syncing: "%{conn} · синхронизация" + summary_block: "Блок %{height} · %{conn}" + nips: + title: "nostr и NIPs" + intro1: "Goblin говорит на nostr — открытом протоколе подписанных сообщений, передаваемых через простые реле-серверы. Ваш кошелёк несёт собственную nostr-личность: отдельный случайный ключ, намеренно независимый от ваших средств и seed. Каждый платёж идёт как сквозно зашифрованное личное сообщение между личностями, со slatepack внутри." + intro2: "goblin.st — это служба имён Goblin: занятие имени публикует там сопоставление имя → ключ (NIP-05), чтобы вам платили на you вместо длинного npub. Имя публично; содержимое платежей — никогда. NIPs — это строительные блоки протокола; коснитесь одного, чтобы прочитать спецификацию." + n05_title: "Имена" + n05_blurb: "Сопоставляет username@goblin.st с вашим ключом, чтобы имена работали как адреса." + n17_title: "Личные сообщения" + n17_blurb: "Зашифрованный конверт DM, в котором идёт каждый платёж." + n44_title: "Шифрование" + n44_blurb: "Аутентифицированный шифр, используемый внутри этих сообщений." + n49_title: "Шифрование ключа" + n49_blurb: "Как секретный ключ хранится в покое, защищённый вашим паролем." + n59_title: "Gift wrap" + n59_blurb: "Оборачивает сообщения, чтобы реле не видели, кто с кем общается." + n98_title: "HTTP-авторизация" + n98_blurb: "Подписывает запрос регистрации имени на goblin.st." + onboarding: + intro: + private_money_head: "Приватные деньги" + private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке." + send_like_message_head: "Отправляйте как сообщение" + send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и mixnet Nym — никто посередине не увидит сумму или участников." + yours_alone_head: "Только ваше" + yours_alone_body: "Ключи, имена и история живут на этом устройстве. На базе кошелька GRIM." + get_started: "Начать" + footnote: "Займёт около минуты. Всё можно изменить позже." + node: + kicker: "ШАГ 1 ИЗ 3 · СЕТЬ" + title: "Как Goblin должен\nследить за цепочкой?" + own_title: "Запустить свой узел" + own_badge: "Приватно" + own_body: "Никому не доверяет — ваш кошелёк проверяет цепочку сам. Синхронизируется в фоне, пока вы завершаете настройку." + connect_title: "Подключиться к узлу" + connect_badge: "Мгновенно" + connect_body: "Без ожидания синхронизации. Выбранный узел может видеть запросы вашего кошелька." + changeable: "Меняется в любой момент в Настройки → Узел." + continue: "Продолжить" + url_invalid: "URL узла должен начинаться с http:// или https://" + wallet: + kicker: "ШАГ 2 ИЗ 3 · КОШЕЛЁК" + title: "Настройте кошелёк" + create_new: "Создать новый" + restore_from_seed: "Восстановить из seed" + name_hint: "Имя кошелька" + password_hint: "Пароль" + repeat_password_hint: "Повторите пароль" + restore_hint: "Подготовьте seed-слова — вы введёте их далее." + create_hint: "Далее вы получите 24 seed-слова для записи. Они — это деньги: кто владеет ими, владеет вашими средствами." + continue: "Продолжить" + passwords_no_match: "Пароли не совпадают" + words: + kicker: "ШАГ 2 ИЗ 3 · КОШЕЛЁК" + title_restore: "Введите seed-слова" + title_create: "Запишите эти слова" + write_down_hint: "На бумаге, по порядку. Любой с этими словами может забрать ваши средства; без них потеря устройства означает потерю средств." + paste: "Вставить" + scan_qr: "Сканировать QR" + copy_clipboard: "Копировать в буфер (избегайте этого)" + restore_wallet: "Восстановить кошелёк" + wrote_them_down: "Я записал их" + fill_every_word: "Заполните каждое слово — коснитесь слова для редактирования или вставьте фразу." + confirm: + kicker: "ШАГ 2 ИЗ 3 · КОШЕЛЁК" + title: "Теперь подтвердите" + enter_hint: "Введите слова, которые только что записали. Коснитесь слова, чтобы ввести его." + paste: "Вставить" + create_wallet: "Создать кошелёк" + keep_going: "Продолжайте — каждое слово, по порядку." + identity: + kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ" + title: "Ваша платёжная личность" + key_being_made: "ключ создаётся…" + connected_nym: "подключено через Nym" + connecting_nym: "подключение через Nym…" + fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства." + clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо." + pick_username: "Выберите имя — необязательно" + username_blurb: "Друзья платят на ваше имя, а не на длинный ключ. Необязательно — можно занять в любой момент." + username_field_hint: "yourname" + working: "Обработка…" + claim_username: "Занять имя" + available_when_connected: "Доступно после подключения mixnet — или пропустите и займите позже." + youre: "Вы %{name}" + claimed_title: "%{name} теперь ваше" + claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк." + open_wallet: "Открыть кошелёк" + skip_for_now: "Пропустить пока" + import_existing: "Уже есть личность Goblin? Импортируйте её" + import_title: "Импорт личности" + import_blurb: "Вставьте свой nsec или выберите файл .backup, чтобы сохранить существующий ключ и имя вместо нового." + errors: + cant_open: "Не удалось открыть кошелёк: %{err}" + cant_create: "Не удалось создать кошелёк: %{err}" + send: + scan_to_request: "Сканируйте для запроса" + scan_to_pay: "Сканируйте для оплаты" + tab_scan: "Сканировать" + tab_my_code: "Мой код" + request_from: "Запросить у" + send_to: "Отправить" + search_hint: "handle, npub или имя" + suggested: "%{icon} Рекомендуемые" + no_contacts: "Пока нет контактов. Найдите кого-то по их handle." + no_profile: "нет профиля" + tag_contact: "контакт" + tag_on_nostr: "в nostr" + searching_nostr: "Поиск в nostr…" + unverified_title: "Заплатить непроверенному ключу?" + unverified_body: "Для этого ключа не опубликован nostr-профиль — он может быть совсем новым, анонимным или с опечаткой. Дважды проверьте перед отправкой." + keep_looking: "Продолжить поиск" + pay_anyway: "Всё равно оплатить" + scan_not_recipient: "Этот QR — не получатель goblin; ожидался npub или handle" + scan_prompt: "Наведите на код goblin, чтобы активировать" + scan_to_pay_me: "Сканируйте, чтобы заплатить мне" + share_btn: "%{icon} Поделиться" + share_message: "Заплатите мне в Goblin — %{handle}\n%{link}\nnpub: %{npub}" + none_found: "Никого не найдено по %{label}" + enter_recipient: "Введите handle, npub или имя" + amount_title: "Сумма" + to_name: "Кому %{name}" + not_enough: "Недостаточно grin" + max: "Макс" + note_label: "Заметка" + note_hint: "Добавить заметку…" + add_note: "Добавить заметку" + edit_note: "Изменить заметку" + note_cancel: "Отмена" + note_save: "Сохранить" + review_btn: "Проверить" + confirm_request: "Подтвердить запрос" + review_title: "Проверка" + requesting_from: "Запрос у %{name}" + youre_sending: "Вы отправляете %{name}" + row_from: "От" + row_to: "Кому" + row_note: "Заметка" + row_they_pay: "Они платят" + row_they_pay_val: "Только если они одобрят" + row_delivery: "Доставка" + row_delivery_val: "Зашифровано NIP-44, через Nym" + row_network_fee: "Сетевая комиссия" + row_network_fee_val: "Списывается с вашего баланса" + row_privacy: "Приватность" + row_privacy_val: "Mimblewimble + Nym" + send_request_btn: "Отправить запрос" + request_approve_hint: "Они получат запрос на одобрение" + hold_to_send: "Удерживайте для отправки" + lower_amount: "Вернуться и уменьшить сумму" + hold_confirm_hint: "Нажмите и удерживайте для подтверждения" + requesting: "Запрос…" + sending: "Отправка…" + they: "Они" + request_blocked: "%{who} не принимает запросы. Попросите их отправить вам grin вместо этого." + failed_request_title: "Не удалось запросить" + failed_send_title: "Не удалось отправить" + failed_request_body: "Не удалось доставить запрос. Попросите их отправить вам grin вместо этого." + failed_send_body: "Платёж не доставлен. Ваш grin в безопасности — попробуйте снова." + try_again_btn: "Попробовать снова" + close_btn: "Закрыть" + success: + requested: "Запрошено" + sent: "Отправлено" + from: "от" + to: "кому" + subtitle: "%{dir} %{who} · только что" + done_btn: "Готово" + receipt_btn: "Квитанция" \ No newline at end of file diff --git a/locales/tr.yml b/locales/tr.yml new file mode 100644 index 00000000..bcd0b90f --- /dev/null +++ b/locales/tr.yml @@ -0,0 +1,808 @@ +lang_name: Türkçe +copy: Kopyala +paste: Yapistir +continue: Devam +complete: Tamamla +error: Hata +retry: Tekrar Dene +close: Kapa +change: Degistir +show: Goster +delete: Sil +clear: Temizle +create: Olustur +id: Id +kernel: Kernel +settings: Ayarlar +language: Dil +scan: Scan +qr_code: QR kod +scan_qr: QR kod tara +repeat: Tekrar +scan_result: Tarama sonucu +back: Geri +share: Paylasmak +theme: 'Tema:' +dark: Karanlik +light: Isik +file: Dosya +choose_file: Dosya seçin +choose_folder: Klasör seç +crash_report: Ariza Raporu +crash_report_warning: Uygulama beklenmedik bir sekilde kapandi son kez, kilitlenme raporunu gelistiricilerle paylasabilirsiniz. +confirmation: Onay +enter_url: URL'yi girin +max_short: MAKS +files_location: Dosya konumu +moving_files: Dosyalari Tasima +wrong_path_error: Yanlis yol belirtildi +check_updates: Başlangiçta güncellemeleri kontrol edin +update_available: Güncelleme mevcut! +changelog: 'Değişiklik Günlüğü:' +wallets: + await_conf_amount: Onay bekleniyor + await_fin_amount: Tamamlanma bekleniyor + locked_amount: Kilitli + txs_empty: 'Koinleri al/gonder icin ekranin altinda bulunan %{receive} / %{send} sekmeleri, cuzdan ayarlar icin %{settings} sekmesini kullanin.' + title: Goblin + create_desc: Yeni cuzdan olustur veya var olan bakiyeli cuzdani kurtarma kelimelerinizle canlandirin. + add: Cuzdan ekle + name: 'Ad:' + pass: 'Sifre:' + pass_empty: Cuzdan Sifresini girin + current_pass: Su anki sifre:' + new_pass: 'Yeni sifre:' + min_tx_conf_count: 'Tx islem için Minimum onay:' + recover: Restore et + recovery_phrase: Kurtarma kelimeleri + words_count: 'Kelime sayisi:' + enter_word: 'Kelimeyi gir #%{sira}:' + not_valid_word: Girilen kelime yanlis + not_valid_phrase: Girilen kurtarma kelimeleri gecerli degil + create_phrase_desc: Kurtarma kelimelerini yazın ve mutlaka saklayin! + restore_phrase_desc: Kaydettiginiz kurtarma kelimelerini girin. + setup_conn_desc: Cuzdan baglanma metodu Sec. + conn_method: Baglanti metodu + ext_conn: 'Harici baglantilar:' + add_node: Node ekle + node_url: 'Node URL:' + node_secret: 'API Secret (optional):' + invalid_url: Girilen URL gecersiz + open: Cuzdani Ac + wrong_pass: Girilen sifre yanlis + locked: Kilitli + unlocked: Kilitsiz + enable_node: 'Cuzdani kullanmak için Tumlesik node etkinlestirin veya ekranin altindaki %{settings} ogesini secerek baska baglanti metodu secin.' + node_loading: 'Cuzdan tumlesik node senkronize olunca yuklenecektir, ekranin altindan baglanma metod %{settings} degistirebilirsiniz.' + loading: Yukleniyor + closing: Kapaniyor + checking: Denetleniyor + default_wallet: Varsayilan cuzdan + new_account_desc: 'Yemi hesap ad girin:' + wallet_loading: Cuzdan yukleniyor + wallet_closing: Cuzdan kapaniyor + wallet_checking: Cuzdan denetleniyor + tx_loading: Islemler yukleniyor + default_account: Varsayilan hesap + accounts: Hesaplar + tx_sent: Gonderildi + tx_received: Alindi + tx_sending: Gonderiliyor + tx_receiving: Aliniyor + tx_confirming: Onaylaniyor + tx_canceled: Iptal edildi + tx_cancelling: Iptal ediliyor + tx_finalizing: Islem tamamlaniyor + tx_posting: Islem kaydetme + tx_confirmed: Onaylandi + txs: Islemler + tx: Islem + messages: Mesajlar + transport: Transferler + input_slatepack_desc: 'Islemi Tamamlamak veya cevap Slatepack olusturmak için mesaji girin:' + parse_slatepack_err: 'Girilen mesaji okurken hata olustu,girilien mesaji tekrar kontrol et:' + pay_balance_error: 'Hesap bakiyesi girilen %{amount} ツ ve ağ ücretini ödemek için yetersiz.' + parse_i1_slatepack_desc: '%{amount} ツ ödemek için bu mesaji aliciya gönderin:' + parse_i2_slatepack_desc: '%{amount} ツ Almak için bu islemi tamamlayin:' + parse_i3_slatepack_desc: '%{amount} almak için mesaji tamamlama islemi postalayin:' + parse_s1_slatepack_desc: '%{amount} ツ almak için mesaji ödeyecek kisiye gönderin:' + parse_s2_slatepack_desc: 'Göndereciğiniz %{amount} ツ islemini tamamlayin:' + parse_s3_slatepack_desc: '%{amount} ツ gönderim tamamlamak için islemi postalayin:' + resp_slatepack_err: 'Cevap slateapack olusturulurken bir hata olustu, girisi kontrol edin:' + resp_exists_err: Bu islem zaten mevcut. + resp_canceled_err: Bu islem zaten iptal edildi. + create_request_desc: 'Para Almak veya göndermek için talep olustur:' + send_request_desc: '%{amount} ツ göndermek için bir istek olusturdunuz. Bu mesaji aliciya gönder:' + send_slatepack_err: Para gönderme isteği olusturulurken bir hata olustu, girisi kontrol edin. + invoice_desc: 'Almak istediginiz tutar %{amount} ツ talebiniz. Slatepack mesajini gondericiye ilet:' + invoice_slatepack_err: Fatura duzenlenirken bir hata olustu, girilen bilgiyi kontrol edin. + finalize_slatepack_err: 'TX islemi tamamlanirken hata olustu, girilen bilgiyi kontrol edin:' + finalize: Tamamla + use_dandelion: Dandelion kullan + enter_amount_send: '%{amount} ツ var. GONDERIM miktari gir:' + enter_amount_receive: 'ALIM miktari gir:' + recovery: Kurtarma + repair_wallet: Cuzdani Onar + repair_desc: Cuzdani check et,yapilmis, gorunmeyen islemler için resynch biraz zaman alir. + repair_unavailable: Cuzdani yeniden tam senkronize etmek için Node baglantisi aktif olmali. + delete: Cuzdani Sil + delete_conf: Cuzdan silinecektir, emin misiniz? + delete_desc: Gelecekte, bakiyeli cuzdaninizi restore etmek için kurtarma kelimelerinizi mutlaka saklayin. + wallet_loading_err: 'Cuzdan senkronize edilirken hata olustu, tekrar deneyin veya ekranin altinda bulunan ayarlar %{settings} ogesinden baglanti metodunu degistirin.' + wallet: Cuzdan + send: Gonder + receive: Al + settings: Cuzdan ayarlar + tx_send_cancel_conf: Gonderim tx iptal + tx_receive_cancel_conf: Gelen tx iptal + rec_phrase_not_found: Sifre kelime bulunmuyor + restore_wallet_desc: Cuzdani restore et + fee_base_desc: 'Ücret (taban değeri%{value}):' + payment_proof: Ödeme kaniti + payment_proof_desc: 'Islemi doğrulamak için alinan ödeme kanitini girin:' + payment_proof_valid: 'Girilen ödeme kaniti geçerlidir:' + payment_proof_error: 'Girilen ödeme kaniti geçerli değildir:' + tx_delete_confirmation: Islemi geçmişten silmek istediğinizden emin misiniz? +transport: + desc: 'Adresten senkronize GONDER veya AL:' + tor_network: Tor network + connected: Baglandi + connecting: Baglaniyor + disconnecting: Baglanti kesiliyor + conn_error: Bagalanti hatasi + disconnected: Baglanti yok + receiver_address: 'Alicinin adresi:' + incorrect_addr_err: 'Girilen adres hatali:' + tor_send_error: Tor adresi uzerinden gonderimde aksaklik olustu, alici online olmasi gerek, islem iptal edildi. + tor_autorun_desc: Islemleri Tor adresi olarak AL,bunun için cuzdan acilisinda Tor hizmetinin baslatilip baslatilmayacagi. + tor_sending: Tor adrese gonderiliyor + tor_settings: Tor Ayarlar + bridges: Bridges + bridges_desc: Setup bridges to bypass Tor network censorship if usual connection is not working. + bin_file: 'Binary file:' + conn_line: 'Baglanti line:' + bridges_disabled: Bridges etkin degil + bridge_name: 'Bridge %{b}' +network: + self: Network + type: 'Network tipi:' + mainnet: Mainnet + testnet: Test agi + connections: Baglantilar + node: Tumlesik node + metrics: Metrikler + mining: Madencilik + settings: Node ayarlar + enable_node: Nodu BASLAT + autorun: Autorun + disabled_server: 'Tumlesik Nodu Baslat veya ust sol kosede %{dots} basarak baska bir baglanti metodu ekleyin.' + no_ips: Sisteminizde hic mevcut IP adresleri yok, server baslatilamadi, network baglantisini kontrol edin. + available: Mevcut + not_available: Mevcut degil + availability_check: Mevcut kontrol + android_warning: Android kullanicilarinin dikkatine. Tümlesik NODE basarili bir sekilde senkronize etmek için telefonunuzun sistem ayarlarinda Grim uygulamasi için bildirimlere erisime izin vermeniz ve pil kullanim kisitlamalarini kaldirmaniz gerekir. Bu, arka planda uygulamanin doğru çalismasi için gerekli bir islemdir. +sync_status: + node_restarting: Node yeniden baslatiliyor + node_down: Node calismiyor + initial: Node bagliyor + no_sync: Node calisiyor. + awaiting_peers: Peers bekleniyor + header_sync: Downloading headers + header_sync_percent: 'Downloading headers: %{percent}%' + tx_hashset_pibd: Indiriyor state (PIBD) + tx_hashset_pibd_percent: 'İndirme durummu (PIBD): %{percent}%' + tx_hashset_download: Downloading state + tx_hashset_download_percent: 'Indiriyor state: %{percent}%' + tx_hashset_setup_history: 'Durum hazirlaniyor (gecmis): %{percent}%' + tx_hashset_setup_position: 'Durum hazirlaniyor (posizyon): %{percent}%' + tx_hashset_setup: Durum hazirlama + tx_hashset_range_proofs_validation: 'Onaylaniyor durum (range proofs): %{percent}%' + tx_hashset_kernels_validation: 'Onaylaniyor durum (kernels): %{percent}%' + tx_hashset_save: Zincir durumu Tamamlaniyor + body_sync: Bloklar Yukleniyor + body_sync_percent: 'Bloklar yukleniyor: %{percent}%' + shutdown: Node kapaniyor +network_node: + header: Header + block: Block + hash: Hash + height: Height + difficulty: Difficulty + time: Time + main_pool: Main pool + stem_pool: Stem pool + data: Data + size: Size (GB) + peers: Peers + error_clean: Node verileri bozuldu, Resync yapmaniz gerekli. + resync: Resync + error_p2p_api: '%{p2p_api} sunucusu baslatilirken bir hata olustu, ekranin altindaki %{settings} ögesini secerek %{p2p_api} ayarlarini kontrol edin.' + error_config: 'Yapilandirmann baslatilmasi sirasinda bir hata olustu; ekranin alt kismindaki %{settings} öğesini seçerek ayarlari kontrol edin.' + error_unknown: 'Baslatma sirasinda bir hata olustu. Ekranin altindaki %{settings} öğesini seçerek Tümlesik NODE ayarlariNi kontrol edin veya yeniden Resync edin.' +network_metrics: + loading: Metrikler senkronizasyondan sonra mevcut olur. + emission: Emission + inflation: Enflasyon + supply: Arz + block_time: Blok zaman + reward: Odul + difficulty_window: 'Difficulty penceresi %{size}' +network_mining: + loading: Madencilik senkronizasyondan sonra mevcut olacak. + info: 'Madencilik server etkinlesti, ayarlar %{settings} ekranin alt koseden degistirilir. Cihaz bagliyken veriler guncelleniyor.' + restart_server_required: Degisiklikleri uygulamak için Server yeniden BASLAT. + rewards_wallet: Odul Cuzdani + server: Stratum server + address: Addres + miners: Madenciler + devices: Cihazlar + blocks_found: Blok bulunan + hashrate: 'Hashrate (C%{bits})' + connected: Baglandi + disconnected: Bagli degil +network_settings: + change_value: Change value + stratum_ip: 'Stratum IP address:' + stratum_port: 'Stratum port:' + port_unavailable: Belirlenen port mevcut degil + restart_node_required: Degisiklikler için yeniden Node BASLAT + choose_wallet: Cüzdan seç + stratum_wallet_warning: Odul almak için cüzdan açilmalidir. + enable: Etkinlestir + disable: Devredisi birak + restart: Restart + server: Server + api_ip: 'API IP address:' + api_port: 'API port:' + api_secret: 'Rest API and V2 Owner API token:' + foreign_api_secret: 'Foreign API token:' + disabled: Mevcut degil + enabled: Mevcut + ftl: 'The Future Time Limit (FTL):' + ftl_description: Blok kabul edilebilmesi için yeni bir bloktaki zaman damgasinin, NODE (saniye cinsinden) yerel saatine gore gelecege ne kadar uzak olabilecegine iliskin sinirlama. + not_valid_value: Girilen deger gecersiz + full_validation: Tam gecerli + full_validation_description: Her blogu islerken tam zincir dogrulamasinin calistirilip calistirilmayacagi (senkronizasyon haric). + archive_mode: Arsiv mode + archive_mode_desc: Tam arsiv NODE calistir (daha fazla disk yeri ve senkronizasyon için zaman gerektirir). + attempt_time: 'Mining attempt time (in seconds):' + attempt_time_desc: The amount of time to attempt to mine on a particular header before stopping and re-collecting transactions from the pool + min_share_diff: 'The minimum acceptable share difficulty:' + reset_settings_desc: Node varsayilan degerlere Resetle + reset_settings: Reset ayarlar + reset: Reset + tx_pool: Transaction pool + pool_fee: 'Poolakabul edilen taban ücret:' + reorg_period: 'Reorg cache retention period (in minutes):' + max_tx_pool: 'Pool icindeki maximum islem sayisi:' + max_tx_stempool: 'Maksimum islem sayisi stem-pool icindeki:' + max_tx_weight: 'Maximum total weight of transactions that can get selected to build a block:' + epoch_duration: 'Epoch duration (in seconds):' + embargo_timer: 'Embargo timer (in seconds):' + aggregation_period: 'Aggregation period (in seconds):' + stem_probability: 'Stem phase probability:' + stem_txs: Stem islemler + p2p_server: P2P server + p2p_port: 'P2P port:' + add_seed: DNS Seed Ekle + seed_address: 'DNS Seed adresi:' + add_peer: Peer ekle + peer_address: 'Peer adresi:' + peer_address_error: 'IP addres veya DNS gir (make sure specified host is available) dogru format, ornek.: 192.168.0.1:1234 or example.com:5678' + default: Varsayilan + allow_list: Izin listesi + allow_list_desc: Sadece bu listedeki Peere baglan. + deny_list: Red listesi + deny_list_desc: Bu listedeki Peer asla baglanma. + favourites: Favoriler + favourites_desc: Baglanti için terchi edilen Peer listesi. + ban_window: 'Banlanan bir Peer (saniye cinsinden) yasakli kalma suresi:' + ban_window_desc: Banlama karari, peerden alinan verilerin dogruluguna bagli olarak Node tarafindan verilir. + max_inbound_count: 'Maksimum gelen Peer baglanti sayisi:' + max_outbound_count: 'Maksimum giden Peer baglanti sayisi:' + reset_data_desc: Node verisini sifirlama. Sadece senkronizasyonda sorun varsa dikkatli kullanin. + reset_data: Verileri sifirlama +modal: + cancel: Iptal + save: Kaydet + add: Ekle +modal_exit: + description: Uygulamadan cikmak için exit, emin misiniz? + exit: Exit +app_settings: + proxy: Proxy + proxy_desc: Uygulamadan gelen ağ istekleri için bir proxy kullanmaya değer mi. +keyboard: + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 5 + 6: 6 + 7: 7 + 8: 8 + 9: 9 + 0: 0 + 01: '-' + q: q + w: w + e: e + r: r + t: t + y: y + u: u + i: i + o: o + p: p + p1: ü + a: a + s: s + d: d + f: f + g: g + h: h + j: j + k: k + l: l + l1: ö + l2: ':' + z: z + x: x + c: c + v: v + b: b + n: n + m: m + m1: ',' + m2: . + m3: / +goblin: + home: + anonymous: "Anonim" + connected_nym: "Nym üzerinden bağlı" + nym_ready: "Nym hazır · relaylar…" + connecting_nym: "Nym'e bağlanılıyor…" + cant_reach_node: "Düğüme ulaşılamıyor" + node_synced: "Düğüm eşitlendi" + syncing: "Eşitleniyor…" + block: "Blok %{height}" + waiting_for_chain: "Zincir bekleniyor…" + nav_wallet: "Cüzdan" + nav_pay: "Öde" + nav_activity: "Etkinlik" + nav_receive: "Al" + nav_settings: "Ayarlar" + activity: "Etkinlik" + empty_title: "Henüz etkinlik yok" + empty_sub: "Başlamak için grin gönder ya da al." + recent: "Son işlemler" + scan_to_pay: "Ödemek için tara" + type_amount: "Bir tutar gir" + request: "İste" + pay: "Öde" + enter_amount: "Ödemek ya da istemek için bir tutar gir" + activity: + canceled: "iptal edildi" + pending: "beklemede" + earlier: "Daha önce" + today: "Bugün" + yesterday: "Dün" + title: "Etkinlik" + requests: "İstekler" + empty_title: "Henüz etkinlik yok" + empty_sub: "Ödemelerin burada görünecek." + pending_header: "Beklemede" + receipt: + title: "Makbuz" + not_found: "İşlem bulunamadı" + for_note: "%{note} için" + details: "İşlem ayrıntıları" + canceled: "İptal edildi" + expired: "Süresi doldu" + funds_returned: "Para iade edildi" + complete: "Tamamlandı" + payment_received: "Ödeme alındı" + payment_sent: "Ödeme başarıyla gönderildi" + pending: "Beklemede" + confs: "%{c}/%{r} onay" + waiting_to_confirm: "Onay bekleniyor" + paying: "Ödeniyor…" + you: "Sen" + to: "Alıcı" + from: "Gönderen" + nostr: "nostr" + fee_none: "Yok" + network_fee: "Ağ ücreti" + privacy: "Gizlilik" + privacy_value: "Mimblewimble + Nym" + transaction: "İşlem" + cancel_request: "İsteği iptal et" + cancel_send: "Ödemeyi iptal et" + cancel_send_confirm: "İptal için tekrar dokun — hâlâ alabilir" + cancel_send_done: "Ödeme iptal edildi — paranız yeniden kullanılabilir" + cancel_send_too_late: "Bu ödeme zaten geçti ve iptal edilemez" + waiting_to_receive: "%{name} alana kadar bekleniyor…" + request: + title: "%{name} istiyor" + approve: "Onayla" + decline: "Reddet" + review_title: "İsteği incele" + hold_to_accept: "Kabul için basılı tut" + hold_accept_hint: "Bu isteği ödemek için basılı tutun" + receive: + title: "Al" + requesting: "%{amt}%{tsu} isteniyor — ödeme almak için paylaş" + clear_request: "İsteği temizle" + share_handle: "Ödeme almak için kullanıcı adını paylaş" + copied: "Kopyalandı" + copy_nostr_id: "nostr kimliğini kopyala" + copy_address: "Adresi kopyala" + copy_npub: "npub kopyala" + share_message: "Goblin'de bana öde (goblin.st) — %{npub}" + privacy_note: "Kullanıcı adın herkese açıktır. Ödeme içeriği ağ üzerinde şifreli kalır." + profile: + title: "Profil" + activity: "Etkinlik" + no_activity: "Henüz onlarla etkinlik yok." + unblock: "Engeli kaldır" + block: "Engelle" + blocked_blurb: "Engellendi — ödemeleri ve istekleri reddediliyor." + block_blurb: "Engellemek, gelen ödeme ve isteklerini düşürür." + settings: + title: "Ayarlar" + connected_nostr: "nostr'a bağlı" + connecting_relays: "Relaylara bağlanılıyor…" + identity: "Kimlik" + copy_npub: "npub kopyala (genel)" + rotate_key: "nostr anahtarını değiştir" + import_identity: "Kimlik içe aktar (.backup / nsec)" + backup_note: "Cihaz mı değiştiriyorsun? İKİSİNİ de yedekle: seed ifaden (bakiye) ve kimlik .backup dosyan (ad + anahtar)." + wallet: "Cüzdan" + display_unit: "Görüntüleme birimi" + relays: "Relaylar" + node: "Düğüm" + slatepacks: "Slatepackler" + slatepacks_value: "Manuel işlem" + lock_wallet: "Cüzdanı kilitle" + switch_wallet: "Cüzdan değiştir" + advanced: "Gelişmiş" + privacy: "Gizlilik" + mixnet_routing: "Mixnet yönlendirme" + messages_lookups: "Mesajlar ve aramalar" + auto_accept: "Otomatik kabul" + pairing: "Eşleştirme" + accept_anyone: "Herkes" + accept_contacts: "Yalnızca kişiler" + accept_ask: "Her zaman sor" + requests: "İstekler" + incoming_requests: "Gelen istekler" + incoming_requests_sub: "Başkalarının senden para istemesine izin ver" + appearance: "Görünüm" + theme: "Tema" + theme_light: "Açık" + theme_dark: "Koyu" + theme_yellow: "Sarı" + archive: "Arşiv" + export_archive: "Arşivi dışa aktar" + wipe_history: "Ödeme geçmişini sil" + about: "Hakkında" + goblin: "Goblin" + build: "Sürüm %{build}" + network: "Ağ" + network_value: "MW + Nym mixnet + nostr" + third_party: "Üçüncü taraf" + grim: "GRIM (üst kaynak cüzdan)" + grin_node: "Grin düğümü" + sp_intro: "Gelişmiş — GRIM'in yaptığı gibi ham slatepackleri elle değiş tokuş et. Bunu yalnızca bir username üzerinden ödeme yapamadığında ya da alamadığında kullan." + sp_receive_group: "Al ya da tamamla" + sp_receive_blurb: "Birinin sana verdiği bir slatepack'i yapıştır. Goblin ödemeyi alır, faturayı öder ya da tamamlayıp zincire gönderir." + sp_process: "Slatepack işle" + sp_paste_first: "Önce bir slatepack yapıştır." + sp_reply_ready: "Yanıt hazır — gönderene geri yolla." + sp_finalizing: "Tamamlanıp zincire gönderiliyor…" + sp_create_group: "Ödeme oluştur" + sp_create_blurb: "Birine vermek için bir slatepack oluştur. Onlar alır, yanıtı geri gönderir, sen de yukarıda tamamlarsın." + sp_amount_hint: "Grin cinsinden tutar" + sp_addr_hint: "Alıcı adresi (isteğe bağlı)" + sp_create: "Slatepack oluştur" + sp_ready: "Slatepack hazır — alıcıya ver." + sp_amount_gt_zero: "Sıfırdan büyük bir tutar gir." + sp_to_send: "Gönderilecek slatepack" + sp_copy: "Slatepack kopyala" + rotate_line1: "• Tamamen yeni RASTGELE bir anahtar alırsın; eski npub artık almaz. Aralarında türetme zinciri yoktur." + rotate_line2: "• Yeni anahtar tohumundan kurtarılamaz — anahtarı değiştirdikten hemen sonra yeni nsec'i yedekle." + rotate_line3: "• Kullanıcı adın SERBEST BIRAKILIR — hemen ardından aynı adı ya da yeni bir ad al (serbest kaldığında başkası da kapabilir)." + rotate_line4: "• Eski anahtara hâlâ yoldaki ödemeler KESİNTİYE uğrar — önce bekleyen ödemelerin bitmesini bekle." + rotate_line5: "• npub'unu doğrudan kaydeden kişiler seni yeniden bulmalı — yeni npub'unu ya da yeniden aldığın username'i paylaş." + cancel: "İptal" + continue: "Devam" + final_confirmation: "Son onay" + rotate_confirm_blurb: "Bu işlem uygulamadan geri alınamaz. Değiştirmek için RESET yaz ve cüzdan parolanı gir." + type_reset: "RESET yaz" + wallet_password: "Cüzdan parolası" + rotate_key_btn: "Anahtarı değiştir" + rotating_key: "Anahtar değiştiriliyor…" + key_rotated: "Anahtar değiştirildi" + new_npub: "Yeni npub: %{npub}" + backup_new_key: "YENİ gizli anahtarı şimdi yedekle — tohumun onu kurtaramaz." + copy_new_nsec: "Yeni nsec yedeğini kopyala" + done: "Bitti" + rotation_failed: "Değiştirme başarısız" + close: "Kapat" + import_identity_title: "Kimlik içe aktar" + import_blurb: "Bu cüzdanın nostr kimliğini değiştirir — bir GOBLIN .backup dosyası seç ya da nsec yapıştır. Yedek ayrıca kullanıcı adını ve geçmişini geri yükler. Hâlâ gerekiyorsa önce mevcut anahtarı yedekle." + import_nsec_hint: "nsec1… veya yapıştırılan yedek" + backup_password_hint: "Yedek parolası (yalnızca başka yerde dışa aktarıldıysa)" + import_btn: "İçe aktar" + importing: "İçe aktarılıyor…" + identity_replaced: "Kimlik değiştirildi" + now_using: "Şu an kullanılan: %{npub}" + import_failed: "İçe aktarma başarısız" + name_authority: "İsim otoritesi" + name_authority_title: "İsim otoritesini değiştir" + name_authority_blurb: "Adları kaydeden ve doğrulayan sunucu. Başka bir örneğe yönlendirerek oradaki adları kullan ve öde." + name_authority_invalid: "Tam bir URL gir (https://…)." + reset: "Sıfırla" + save: "Kaydet" + backup_file: "Dosyaya yedekle" + choose_backup_file: "Bir .backup dosyası seç" + backup_read_failed: "Dosya okunamadı." + backup_saved: "Yedek kaydedildi" + backup_saved_sub: ".backup dosyasını güvende tut — hem ona hem de parolana sahip olan kimliğini geri yükleyebilir." + backup_file_title: "Kimliği yedekle" + backup_file_blurb: "Kullanıcı adın ve anahtarınla tek bir şifreli .backup dosyası oluşturur. Mühürlemek için cüzdan parolanı gir." + backup_write_failed: "Dosya kaydedilemedi." + create_backup: "Yedek oluştur" + registered: "%{name} kaydedildi" + released_msg: "Bırakıldı — ad artık alınabilir" + release_confirm: "%{name} bırakılsın mı?" + release_blurb: "Serbest kalır kalmaz herkes alabilir — döndüğün bir sonraki anahtar dahil. 10 dakika boyunca başka bir kullanıcı adı kaydedemezsin." + releasing: "Bırakılıyor…" + keep_it: "Vazgeç" + release_it: "Bırak" + username: "Kullanıcı adı" + username_note: "you olarak gösterilir. goblin.st'de herkese açık. Ödemeler şifreli kalır." + release_username: "Kullanıcı adını bırak" + pick_username: "Bir kullanıcı adı seç — isteğe bağlı" + working: "Çalışıyor…" + claim: "Al" + err_just_taken: "O kullanıcı adı az önce alındı" + err_cooldown: "Yakın zamanda bir kullanıcı adı bıraktın — 10 dakika içinde yenisini kaydedebilirsin." + err_unreachable: "goblin.st'ye ulaşılamadı — bağlantı sorunu. Tekrar dene." + err_release: "Bırakılamadı: %{err}" + avail_available: "Müsait!" + avail_taken: "Alınmış" + avail_reserved: "Ayrılmış" + avail_invalid: "Adlar 3–20 karakter: a–z, 0–9, _ ya da -" + avail_quarantined: "Müsait değil" + avail_unknown: "Kontrol edilemedi — bağlantı sorunu. Tekrar dene." + advanced: + title: "Gelişmiş" + intro: "GRIM'den düşük seviyeli cüzdan araçları. Bunlara normalde ihtiyacın olmaz." + own_node_desc: "Herkese açık bir düğüme güvenmek yerine bu cihazda tam bir Grin düğümü senkronize edin." + own_node_active: "Kendi düğümünüz çalışıyor" + repair: "Cüzdanı onar" + repair_desc: "Zinciri yeniden tara ve eksik çıktıları geri yükle. Bu biraz zaman alabilir." + repair_unavailable: "Önce senkronize bir düğüm bağlantısı gerekir." + repairing: "Onarılıyor… %{pct}%" + restore: "Cüzdanı geri yükle" + restore_desc: "Yerel verileri sil ve tohumundan yeniden oluştur. Onarım işe yaramadıysa bunu kullan — sonra cüzdanı yeniden açarsın." + restore_confirm: "Geri yüklemek için tekrar dokun" + show_phrase: "Kurtarma ifadesi" + phrase_desc: "24 grin tohum kelimen — fonları kurtarmanın tek yolu. Onları çevrimdışı ve gizli tut." + reveal: "İfadeyi göster" + hide: "Gizle" + password: "Cüzdan parolası" + wrong_password: "Yanlış parola." + delete: "Cüzdanı sil" + delete_desc: "Bu cüzdanı bu cihazdan kalıcı olarak kaldır. Tohumun olmadan fonlar kurtarılamaz." + delete_confirm: "Silmek için tekrar dokun" + manage_node: "Düğüm bağlantısını yönet" + repair_confirm: "Evet, şimdi onar" + repair_confirm_note: "Onarım zinciri yeniden tarar ve birkaç dakika sürebilir." + restore_confirm_note: "Bu, yerel verileri siler ve seed'inizden yeniden oluşturur — birkaç dakika sürebilir." + privacy: + title: "Ağ gizliliği" + intro: "Goblin özel trafiğini Nym mixnet üzerinden gönderir — kimin kiminle konuştuğunu gizleyen beş atlamalı bir ağ, böylece bir relay bir ödemeyi sana bağlayamaz." + payments: "Ödemeler" + payments_blurb: "Slatepack taşıyan her nostr mesajı." + usernames: "usernamelar" + usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları." + price_avatars: "Fiyat" + price_avatars_blurb: "Tutarların yanında gösterilen anlık kur." + over_mixnet: "Mixnet üzerinden" + direct_connection: "Doğrudan bağlantı" + grin_node: "Grin düğümü" + grin_node_blurb: "Blok eşitleme ve işlemini ağa yayma. Bu, herkes için aynı olan genel zincir verisidir ve kimliğinle ilişkilendirilmez." + pairing: + title: "Eşleştirme" + intro: "Bakiyenin ve tutarların neye göre gösterildiği." + pair_with: "Eşleştir" + rates_note: "Kurlar yalnızca bir eşleştirme açıkken Nym mixnet üzerinden alınır — kapalıysa cihazından hiçbir kur isteği çıkmaz." + relays: + title: "Relaylar" + intro: "Ödeme mesajları aşağıdaki her relay'e yansıtılır; almak için ulaşılabilir tek bir relay yeterlidir." + your_relays: "Relaylarn" + add_relay: "Relay ekle" + add_relay_btn: "Relay ekle" + save_reconnect: "Kaydet ve yeniden bağlan" + none: "yok" + count: "%{n} relay" + node: + title: "Düğüm" + connection: "Bağlantı" + integrated: "Tümleşik düğüm" + applies_after: "Cüzdan kilitlenip yeniden açıldıktan sonra geçerli olur." + add_external: "Harici düğüm ekle" + api_secret_hint: "API gizli anahtarı (isteğe bağlı)" + add_node: "Düğüm ekle" + integrated_host: "tümleşik düğüm" + summary_syncing: "%{conn} · eşitleniyor" + summary_block: "Blok %{height} · %{conn}" + nips: + title: "nostr ve NIPler" + intro1: "Goblin nostr konuşur — basit relay sunucuları üzerinden geçen imzalı mesajların açık bir protokolü. Cüzdanın kendi nostr kimliğini taşır: bağımsız rastgele bir anahtar, paran ve tohumundan kasıtlı olarak ayrı tutulur. Her ödeme, slatepack içinde olacak şekilde, kimlikler arasında uçtan uca şifreli bir doğrudan mesaj olarak gider." + intro2: "goblin.st, Goblin'in ad servisidir: bir kullanıcı adı almak orada bir ad → anahtar eşlemesi yayımlar (NIP-05), böylece insanlar uzun bir npub yerine you'ya ödeme yapabilir. Kullanıcı adı herkese açıktır; ödeme içeriği asla değil. NIPler protokolün yapı taşlarıdır — özelliği okumak için birine dokun." + n05_title: "Adlar" + n05_blurb: "username@goblin.st'yi anahtarına eşler, böylece kullanıcı adları adres gibi çalışır." + n17_title: "Özel mesajlar" + n17_blurb: "Her ödemenin içinde gittiği şifreli DM zarfı." + n44_title: "Şifreleme" + n44_blurb: "Bu mesajların içinde kullanılan kimlik doğrulamalı şifreleme." + n49_title: "Anahtar şifreleme" + n49_blurb: "Gizli anahtarın parolanla kilitli olarak nasıl depolandığı." + n59_title: "Hediye paketi" + n59_blurb: "Mesajları sarar, böylece relaylar kimin kiminle konuştuğunu göremez." + n98_title: "HTTP kimlik doğrulama" + n98_blurb: "goblin.st'ye gönderilen kullanıcı adı kayıt isteğini imzalar." + onboarding: + intro: + private_money_head: "Özel para" + private_money_body: "Goblin, grin için bir cüzdan — zincirinde tutar ya da adres bulunmayan dijital nakit." + send_like_message_head: "Mesaj gibi gönder" + send_like_message_body: "Bir username ya da npub'a öde, nostr ve Nym mixnet üzerinden uçtan uca şifreli bir mesaj olarak ulaşır — aradaki hiç kimse tutarı ya da kimlerin dahil olduğunu göremez." + yours_alone_head: "Yalnızca senin" + yours_alone_body: "Anahtarlar, adlar ve geçmiş bu cihazda yaşar. GRIM cüzdanı üzerine kuruludur." + get_started: "Başla" + footnote: "Yaklaşık bir dakika sürer. Her şeyi sonradan değiştirebilirsin." + node: + kicker: "ADIM 1 / 3 · AĞ" + title: "Goblin zinciri nasıl\nizlesin?" + own_title: "Kendi düğümümü çalıştır" + own_badge: "Özel" + own_body: "Kimseye güvenmez — cüzdanın zinciri kendisi kontrol eder. Sen kurulumu bitirirken arka planda eşitlenir." + connect_title: "Bir düğüme bağlan" + connect_badge: "Anında" + connect_body: "Eşitleme beklemesi yok. Seçtiğin düğüm cüzdanının sorgularını görebilir." + changeable: "Ayarlar → Düğüm'den istediğin zaman değiştirilebilir." + continue: "Devam" + url_invalid: "Düğüm URL'si http:// ya da https:// ile başlamalı" + wallet: + kicker: "ADIM 2 / 3 · CÜZDAN" + title: "Cüzdanını kur" + create_new: "Yeni oluştur" + restore_from_seed: "Tohumdan geri yükle" + name_hint: "Cüzdan adı" + password_hint: "Parola" + repeat_password_hint: "Parolayı tekrarla" + restore_hint: "Tohum kelimelerini hazır tut — onları sonra gireceksin." + create_hint: "Sırada yazman için 24 tohum kelimesi var. Onlar paradır — onları elinde tutan paranı elinde tutar." + continue: "Devam" + passwords_no_match: "Parolalar eşleşmiyor" + words: + kicker: "ADIM 2 / 3 · CÜZDAN" + title_restore: "Tohum kelimelerini gir" + title_create: "Bu kelimeleri yaz" + write_down_hint: "Kâğıda, sırayla. Bu kelimelere sahip olan paranı alabilir; onlar olmadan kaybolan bir cihaz kaybolan para demektir." + paste: "Yapıştır" + scan_qr: "QR tara" + copy_clipboard: "Panoya kopyala (bundan kaçın)" + restore_wallet: "Cüzdanı geri yükle" + wrote_them_down: "Onları yazdım" + fill_every_word: "Her kelimeyi doldur — düzenlemek için bir kelimeye dokun ya da ifadeyi yapıştır." + confirm: + kicker: "ADIM 2 / 3 · CÜZDAN" + title: "Şimdi kanıtla" + enter_hint: "Az önce yazdığın kelimeleri gir. Yazmak için bir kelimeye dokun." + paste: "Yapıştır" + create_wallet: "Cüzdan oluştur" + keep_going: "Devam et — her kelime, sırayla." + identity: + kicker: "ADIM 3 / 3 · KİMLİK" + title: "Ödeme kimliğin" + key_being_made: "anahtar oluşturuluyor…" + connected_nym: "Nym üzerinden bağlı" + connecting_nym: "Nym üzerinden bağlanılıyor…" + fresh_key_blurb: "Seed'inin parçası olmayan bir ödeme anahtarı — paranı hiç ellemeden istediğin an döndür." + clean_slate_blurb: "Temiz bir sayfa mı istiyorsun? İstediğin zaman yepyeni bir anahtar tak — yeni sen eskisine bağlı değil. Aynı cüzdan, yeni yüz." + pick_username: "Bir kullanıcı adı seç — isteğe bağlı" + username_blurb: "Arkadaşların uzun bir anahtar yerine adına öder. İsteğe bağlı — istediğin an al." + username_field_hint: "adınız" + working: "Çalışıyor…" + claim_username: "Kullanıcı adı al" + available_when_connected: "Mixnet bağlandığında müsait — ya da atla ve sonra al." + youre: "Sen %{name}'sin" + claimed_title: "%{name} artık senin" + claimed_blurb: "Arkadaşların artık sana adınla ödeme yapabilir. Her şey hazır — cüzdanını aç." + open_wallet: "Cüzdanımı aç" + skip_for_now: "Şimdilik atla" + import_existing: "Zaten bir Goblin kimliğin var mı? İçe aktar" + import_title: "Kimliğini içe aktar" + import_blurb: "Bu yeni anahtar yerine mevcut anahtarını ve kullanıcı adını korumak için nsec'ini yapıştır veya bir .backup dosyası seç." + errors: + cant_open: "Cüzdan açılamadı: %{err}" + cant_create: "Cüzdan oluşturulamadı: %{err}" + send: + scan_to_request: "İstemek için tara" + scan_to_pay: "Ödemek için tara" + tab_scan: "Tara" + tab_my_code: "Kodum" + request_from: "Şundan iste" + send_to: "Şuna gönder" + search_hint: "handle, npub ya da ad" + suggested: "%{icon} Önerilen" + no_contacts: "Henüz kişi yok. Birini handle ile bul." + no_profile: "profil yok" + tag_contact: "kişi" + tag_on_nostr: "nostr'da" + searching_nostr: "nostr aranıyor…" + unverified_title: "Doğrulanmamış bir anahtara ödeme yapılsın mı?" + unverified_body: "Bu anahtar için yayımlanmış bir nostr profili yok — yepyeni, anonim ya da yanlış yazılmış olabilir. Göndermeden önce doğru olduğunu iki kez kontrol et." + keep_looking: "Aramaya devam et" + pay_anyway: "Yine de öde" + scan_not_recipient: "O QR bir goblin alıcısı değil — bir npub ya da handle bekleniyordu" + scan_prompt: "Etkinleştirmek için bir goblin kodunu görüntüye getir" + scan_to_pay_me: "Bana ödemek için tara" + share_btn: "%{icon} Paylaş" + share_message: "Goblin'de bana öde — %{handle}\n%{link}\nnpub: %{npub}" + none_found: "%{label} için kimse bulunamadı" + enter_recipient: "Bir handle, npub ya da ad gir" + amount_title: "Tutar" + to_name: "%{name} için" + not_enough: "Yeterli grin yok" + max: "Maks" + note_label: "Not" + note_hint: "Bir not ekle…" + add_note: "Not ekle" + edit_note: "Notu düzenle" + note_cancel: "İptal" + note_save: "Kaydet" + review_btn: "İncele" + confirm_request: "İsteği onayla" + review_title: "İncele" + requesting_from: "%{name} kişisinden isteniyor" + youre_sending: "%{name} kişisine gönderiyorsun" + row_from: "Gönderen" + row_to: "Alıcı" + row_note: "Not" + row_they_pay: "Onlar öder" + row_they_pay_val: "Yalnızca onaylarlarsa" + row_delivery: "Teslimat" + row_delivery_val: "NIP-44 şifreli, Nym üzerinden" + row_network_fee: "Ağ ücreti" + row_network_fee_val: "Bakiyenden düşülür" + row_privacy: "Gizlilik" + row_privacy_val: "Mimblewimble + Nym" + send_request_btn: "İstek gönder" + request_approve_hint: "Onaylamaları için bir istek alacaklar" + hold_to_send: "Göndermek için basılı tut" + lower_amount: "Geri dön ve tutarı düşür" + hold_confirm_hint: "Onaylamak için basılı tut" + requesting: "İsteniyor…" + sending: "Gönderiliyor…" + they: "Onlar" + request_blocked: "%{who} istek kabul etmiyor. Bunun yerine sana grin göndermesini iste." + failed_request_title: "İstenemedi" + failed_send_title: "Gönderilemedi" + failed_request_body: "İsteği teslim edemedik. Bunun yerine sana grin göndermesini iste." + failed_send_body: "Ödeme teslim edilemedi. Grin'in güvende — tekrar dene." + try_again_btn: "Tekrar dene" + close_btn: "Kapat" + success: + requested: "İstendi" + sent: "Gönderildi" + from: "şuradan" + to: "şuraya" + subtitle: "%{dir} %{who} · az önce" + done_btn: "Bitti" + receipt_btn: "Makbuz" \ No newline at end of file diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml new file mode 100644 index 00000000..0a000b72 --- /dev/null +++ b/locales/zh-CN.yml @@ -0,0 +1,808 @@ +lang_name: 英语 +copy: 复制 +paste: 粘贴 +continue: 继续 +complete: 完成 +error: 错误 +retry: 重试 +close: 关闭 +change: 更改 +show: 显示 +delete: 删除 +clear: 清楚 +create: 创建 +id: 标识 +kernel: 核心 +settings: 设置 +language: 语言 +scan: 扫描 +qr_code: 二维码 +scan_qr: 扫描二维码 +repeat: 重复 +scan_result: 扫描结果 +back: 返回 +share: 分享 +theme: '主题:' +dark: 深色 +light: 淡色 +file: 文件 +choose_file: 选择文件 +choose_folder: 选择文件夹 +crash_report: 崩溃报告 +crash_report_warning: 上次应用程序意外关闭,您可以报告开发人员崩溃事件. +confirmation: 确认 +enter_url: 输入 URL +max_short: 最大數量 +files_location: 檔案位置 +moving_files: 檔案移動 +wrong_path_error: 指定錯誤路徑 +check_updates: 啟動時請查看更新 +update_available: 最新消息已发布! +changelog: '更新日誌:' +wallets: + await_conf_amount: 等待确认中 + await_fin_amount: 等待确定中 + locked_amount: 锁定帐户 + txs_empty: '手动接收资金或通过传输接收资金 %{message} or %{transport} 更改钱包设置, 请按屏幕底部的按钮 %{settings} 按钮.' + title: Goblin + create_desc: 创建或种子单词导入已有钱包. + add: 添加钱包 + name: '用户名:' + pass: '密码:' + pass_empty: 输入钱包的密码 + current_pass: '目前密码:' + new_pass: '新密码:' + min_tx_conf_count: '确认交易的最低数量:' + recover: 恢复 + recovery_phrase: 助记词 + words_count: '字数:' + enter_word: '输入单词 #%{number}:' + not_valid_word: 输入的单词无效 + not_valid_phrase: 输入的助记词无效 + create_phrase_desc: 已安全地写下并保存助记词. + restore_phrase_desc: 从已保存的助记词中输入. + setup_conn_desc: 选择钱包连接到网络的方式. + conn_method: 连接方式 + ext_conn: '外部连接:' + add_node: 添加节点 + node_url: '节点网址:' + node_secret: 'API 密钥 (可选):' + invalid_url: 输入的网址无效 + open: 打开钱包 + wrong_pass: 输入的密码错误 + locked: 已锁定 + unlocked: 解锁 + enable_node: '通过选择屏幕底部的按钮 %{settings} 启用集成节点以使用钱包或更改连接设置.' + node_loading: '集成节点同步后钱包会加载,你可选择屏幕底部的按钮 %{settings} 更改连接.' + loading: 正在加载 + closing: 正在关闭 + checking: 检查中 + default_wallet: 默认钱包 + new_account_desc: '输入新帐户的名称:' + wallet_loading: 加载钱包 + wallet_closing: 关闭钱包 + wallet_checking: 检查钱包 + tx_loading: 加载事务 + default_account: 默认账户 + accounts: 账户 + tx_sent: 已发送 + tx_received: 已接收 + tx_sending: 发送中 + tx_receiving: 接收中 + tx_confirming: 等待确认 + tx_canceled: 已取消 + tx_cancelling: 取消 + tx_finalizing: 完成 + tx_posting: 过账交易 + tx_confirmed: 已确认 + txs: 所有交易 + tx: 交易 + messages: 消息 + transport: 传输 + input_slatepack_desc: '输入收到的 Slatepack 消息创建响应或完成的请求:' + parse_slatepack_err: '读取消息时出错,请检查输入:' + pay_balance_error: '账户余额不足以支付 %{amount} ツ 和网络费用.' + parse_i1_slatepack_desc: '要支付 %{amount} ツ 请将此消息发送给接收者:' + parse_i2_slatepack_desc: '完成交易以接收 %{amount} ツ:' + parse_i3_slatepack_desc: '发布交易以完成 %{amount} ツ的接收 ツ:' + parse_s1_slatepack_desc: '要接收 %{amount} ツ 请将此消息发送给发件人:' + parse_s2_slatepack_desc: '完成交易以发送 %{amount} ツ:' + parse_s3_slatepack_desc: '发布交易以完成 %{amount} ツ的发送:' + resp_slatepack_err: '创建响应时出错,请检查输入数据或重试:' + resp_exists_err: 此交易已存在. + resp_canceled_err: 此交易已被取消. + create_request_desc: '创建发送或接收资金的请求:' + send_request_desc: '您已创建发送请求 %{amount} ツ. 将此消息发送给接收者:' + send_slatepack_err: 创建发送资金请求时出错,请检查输入数据或重试. + invoice_desc: '您已创建接收请求 %{amount} ツ. 将此消息发送给发送者:' + invoice_slatepack_err: 发票开具时出错,请检查输入数据或重试. + finalize_slatepack_err: '完结时出错,请检查输入数据或重试:' + finalize: 完成 + use_dandelion: 使用蒲公英 + enter_amount_send: '你有 %{amount} ツ. 输入要发送的金额:' + enter_amount_receive: '输入要接收的金额:' + recovery: 恢复 + repair_wallet: 修复钱包 + repair_desc: 检查钱包,必要时修复和恢复丢失的输出. 此操作需要时间. + repair_unavailable: 您需要与节点建立有效连接并完成钱包同步. + delete: 删除钱包 + delete_conf: 您确定要删除钱包吗? + delete_desc: 确保您已保存恢复助记语,以便日后使用资金。. + wallet_loading_err: '同步钱包时出错,你可以通过选择屏幕底部的按钮 %{settings} 来重试或更改连接设置.' + wallet: 钱包 + send: 发送 + receive: 接收 + settings: 钱包设置 + tx_send_cancel_conf: '您确定要取消 %{amount} ツ的发送吗?' + tx_receive_cancel_conf: '您确定要取消 %{amount} ツ的接收吗?' + rec_phrase_not_found: 找不到恢复助记词. + restore_wallet_desc: 如果常规修复没有帮助,通过删除所有文件来恢复钱包.您将需要重新打开您的钱包. + fee_base_desc: '费用 (基值%{value}):' + payment_proof: 付款證明 + payment_proof_desc: '輸入已收款證明以驗證交易:' + payment_proof_valid: '輸入的付款證明有效:' + payment_proof_error: '輸入的付款證明無效:' + tx_delete_confirmation: 你確定要從歷史紀錄中刪除這筆交易嗎? +transport: + desc: '使用传输同步接收或发送消息:' + tor_network: Tor 网络 + connected: 已连接 + connecting: 正在连接 + disconnecting: 断开连接 + conn_error: 连接错误 + disconnected: 已断开连接 + receiver_address: '接收者的地址:' + incorrect_addr_err: '输入的地址不正确:' + tor_send_error: 通过 Tor 发送时出错,请确保接收方在线, 交易已取消. + tor_autorun_desc: 是否在开钱包时启动 Tor 服务以同步接收交易. + tor_sending: 通过 Tor 发送 + tor_settings: Tor 设置 + bridges: 桥梁 + bridges_desc: 如果常规连接不正常,设置网桥,可以绕过 Tor 网络审查. + bin_file: '二进制文件:' + conn_line: '连接线:' + bridges_disabled: 网桥已禁用 + bridge_name: '网桥%{b}' +network: + self: 网络 + type: '网络类型:' + mainnet: 主网 + testnet: 测试网 + connections: 连接 + node: 集成节点 + metrics: 指标 + mining: 挖矿 + settings: 节点设置 + enable_node: 启用节点 + autorun: 自动运行 + disabled_server: '按屏幕左上角的按钮 %{dots}启用集成节点或添加其他连接方法.' + no_ips: T您的系统上没有可用的 IP 地址,服务器无法启动,请检查您的网络连接. + available: 可用 + not_available: 不可用 + availability_check: 检查是否可用 + android_warning: Android 用户注意 .要成功同步集成节点,您必须在手机的系统设置中允许访问通知并取消 Grim 应用程序的电池使用限制.这是在后台正确运行应用程序的必要操作. +sync_status: + node_restarting: 节点正在重新启动 + node_down: 节点已关闭 + initial: 节点正在启动 + no_sync: 节点正在运行 + awaiting_peers: 等待网络对点 + header_sync: 正下载标题 + header_sync_percent: '正在下载标题: %{percent}%' + tx_hashset_pibd: 下载状态 (PIBD) + tx_hashset_pibd_percent: '下载状态 (PIBD): %{percent}%' + tx_hashset_download: 正在下载状态 + tx_hashset_download_percent: '下载状态: %{percent}%' + tx_hashset_setup_history: '正在准备状态(历史记录): %{percent}%' + tx_hashset_setup_position: '正在准备状态(位置): %{percent}%' + tx_hashset_setup: 正在准备状态 + tx_hashset_range_proofs_validation: '验证状态(范围证明): %{percent}%' + tx_hashset_kernels_validation: '正在验证状态(核心): %{percent}%' + tx_hashset_save: 最终确定链状态 + body_sync: 下载区块 + body_sync_percent: '下载区块中: %{percent}%' + shutdown: 节点正在关闭 +network_node: + header: 标题 + block: 区块 + hash: 哈希值 + height: 高度 + difficulty: 难度 + time: 时间 + main_pool: 主池 + stem_pool: stem池 + data: 数据 + size: 大小 (GB) + peers: 网络对点 + error_clean: 点数据已损坏,需要重新同步. + resync: 重新同步 + error_p2p_api: '%{p2p_api} 服务器初始化时出错,请选择屏幕底部的按钮 %{p2p_api} 来检查 %{settings}设置.' + error_config: '配置初始化时出错,请选择屏幕底部的按钮 %{settings} 检查设置.' + error_unknown: '初始化时出错,请选择屏幕底部的按钮 %{settings} 来检查集成节点设置,或者重新同步.' +network_metrics: + loading: 指标在同步后将可用 + emission: 发射 + inflation: 通货膨胀 + supply: 供应 + block_time: Block time + reward: 奖励 + difficulty_window: '难度窗口 %{size}' +network_mining: + loading: 同步后即可挖矿 + info: '挖矿服务器已启用,您可以通过选择屏幕底部的按钮 %{settings} 来更改其设置。连接设备后,数据会更新.' + restart_server_required: 需要重启服务器才能应用更改. + rewards_wallet: 奖励钱包 + server: 阶层服务器 + address: 地址 + miners: 矿工 + devices: 设备 + blocks_found: 找到的区块 + hashrate: '哈希率 (C%{bits})' + connected: 已连接 + disconnected: 已断开连接 +network_settings: + change_value: 更改值 + stratum_ip: '层 IP 地址:' + stratum_port: '层端口:' + port_unavailable: 指定的端口不可用 + restart_node_required: 需要重启节点才能应用更改. + choose_wallet: 选择钱包 + stratum_wallet_warning: 必须打开钱包才能获得奖励. + enable: 启用 + disable: 禁用 + restart: 重新启动 + server: 服务器 + api_ip: 'API IP 地址:' + api_port: 'API 端口:' + api_secret: '其它API 和 V2 所有者 API 令牌:' + foreign_api_secret: '外部 API 令牌:' + disabled: 已禁用 + enabled: 已启用 + ftl: '未来时间限制 (FTL):' + ftl_description: 限制未来多长时间, 相对于节点的本地时间,以秒为单位, 新区块的时间戳可以被接受. + not_valid_value: 输入的值无效 + full_validation: 完全验证 + full_validation_description: 在处理每个区块时是否运行全链验证(同步期间除外). + archive_mode: 存档模式 + archive_mode_desc: 以全部存档模式运行全节点(同步需要更多的磁盘空间和时间). + attempt_time: '尝试挖矿时间 (秒):' + attempt_time_desc: 在停止并从池中重新收集交易之前尝试对特定标题进行挖矿的时间 + min_share_diff: '可接受的最低份额难度:' + reset_settings_desc: 将节点设置重置为默认值 + reset_settings: 重置设置 + reset: 重置 + tx_pool: 交易池 + pool_fee: '接受到矿池的基本费用:' + reorg_period: '重组缓存保留期(以分钟为单位):' + max_tx_pool: '池中的最大交易数:' + max_tx_stempool: 'stem池中的最大交易数:' + max_tx_weight: '可以选择构建区块交易的最大总权重:' + epoch_duration: '纪元持续时间(以秒为单位):' + embargo_timer: '禁止计时器(以秒为单位):' + aggregation_period: '聚合周期(以秒为单位):' + stem_probability: 'stem助记词概率:' + stem_txs: stem交易 + p2p_server: P2P 服务器 + p2p_port: 'P2P 端口:' + add_seed: 添加 DNS 种子 + seed_address: 'DNS 种子地址:' + add_peer: 添加网络对点 + peer_address: '网络对点地址:' + peer_address_error: '以正确的格式输入 IP 地址或 DNS 名称(确保指定的主机可用),例如:192.168.0.1:1234 或 example.com:5678' + default: 默认 + allow_list: 允许列表 + allow_list_desc: 仅连接到此列表中的网络对点. + deny_list: 拒绝列表 + deny_list_desc: 切勿连接到此列表中的网络对点. + favourites: 收藏夹 + favourites_desc: 要连接的首选网络对点列表. + ban_window: '被封禁的网络对点应该保持被封禁多长时间(以秒为单位):' + ban_window_desc: 禁止的决定是由节点 根据从网络对点收到的数据的正确性做出的. + max_inbound_count: '入站网络对点连接的最大数量:' + max_outbound_count: '最大出站网络对点连接数:' + reset_data_desc: 重置节点数据。只有在出现同步问题时才需谨慎使用. + reset_data: 重置数据 +modal: + cancel: 取消 + save: 保存 + add: 添加 +modal_exit: + description: 您确定要退出应用程序吗? + exit: 退出手 +app_settings: + proxy: 代理 + proxy_desc: 是否值得对来自应用程序的网络请求使用代理. +keyboard: + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 5 + 6: 6 + 7: 7 + 8: 8 + 9: 9 + 0: 0 + 01: '-' + q: 手 + w: 田 + e: 水 + r: 口 + t: 廿 + y: 卜 + u: 山 + i: 戈 + o: 人 + p: 心 + p1: '"' + a: 日 + s: 尸 + d: 木 + f: 火 + g: 土 + h: 竹 + j: 十 + k: 大 + l: 中 + l1: \ + l2: ':' + z: 重 + x: 難 + c: 金 + v: 女 + b: 月 + n: 弓 + m: 一 + m1: ',' + m2: . + m3: / +goblin: + home: + anonymous: "匿名" + connected_nym: "已通过 Nym 连接" + nym_ready: "Nym 就绪 · 连接中继…" + connecting_nym: "正在连接 Nym…" + cant_reach_node: "无法连接节点" + node_synced: "节点已同步" + syncing: "同步中…" + block: "区块 %{height}" + waiting_for_chain: "等待链数据…" + nav_wallet: "钱包" + nav_pay: "支付" + nav_activity: "动态" + nav_receive: "收款" + nav_settings: "设置" + activity: "动态" + empty_title: "暂无动态" + empty_sub: "收发 grin 即可开始。" + recent: "最近" + scan_to_pay: "扫码支付" + type_amount: "输入金额" + request: "请求" + pay: "支付" + enter_amount: "输入要支付或请求的金额" + activity: + canceled: "已取消" + pending: "待处理" + earlier: "更早" + today: "今天" + yesterday: "昨天" + title: "动态" + requests: "请求" + empty_title: "暂无动态" + empty_sub: "你的付款将显示在这里。" + pending_header: "待处理" + receipt: + title: "收据" + not_found: "未找到交易" + for_note: "用于 %{note}" + details: "交易详情" + canceled: "已取消" + expired: "已过期" + funds_returned: "资金已退回" + complete: "已完成" + payment_received: "已收到付款" + payment_sent: "付款发送成功" + pending: "待处理" + confs: "%{c}/%{r} 次确认" + waiting_to_confirm: "等待确认" + paying: "支付中…" + you: "你" + to: "收款方" + from: "付款方" + nostr: "nostr" + fee_none: "无" + network_fee: "网络费用" + privacy: "隐私" + privacy_value: "Mimblewimble + Nym" + transaction: "交易" + cancel_request: "取消请求" + cancel_send: "取消付款" + cancel_send_confirm: "再次点按以取消 — 对方可能仍会收到" + cancel_send_done: "付款已取消 — 你的资金已重新可用" + cancel_send_too_late: "这笔付款已经完成,无法取消" + waiting_to_receive: "等待 %{name} 接收…" + request: + title: "%{name} 发起请求" + approve: "同意" + decline: "拒绝" + review_title: "审核请求" + hold_to_accept: "按住以接受" + hold_accept_hint: "按住以支付此请求" + receive: + title: "收款" + requesting: "正在请求 %{amt}%{tsu} — 分享以收款" + clear_request: "清除请求" + share_handle: "分享你的用户名以收款" + copied: "已复制" + copy_nostr_id: "复制 nostr ID" + copy_address: "复制地址" + copy_npub: "复制 npub" + share_message: "在 Goblin 上向我付款 (goblin.st) — %{npub}" + privacy_note: "你的用户名是公开的。付款内容在网络中保持加密。" + profile: + title: "资料" + activity: "动态" + no_activity: "尚无往来记录。" + unblock: "取消屏蔽" + block: "屏蔽" + blocked_blurb: "已屏蔽 — 其付款和请求会被丢弃。" + block_blurb: "屏蔽后将丢弃对方发来的付款和请求。" + settings: + title: "设置" + connected_nostr: "已连接 nostr" + connecting_relays: "正在连接中继…" + identity: "身份" + copy_npub: "复制 npub(公开)" + rotate_key: "轮换 nostr 密钥" + import_identity: "导入身份(.backup / nsec)" + backup_note: "更换设备?两者都要备份:你的助记词(资金)和身份 .backup 文件(名称 + 密钥)。" + wallet: "钱包" + display_unit: "显示单位" + relays: "中继" + node: "节点" + slatepacks: "Slatepack" + slatepacks_value: "手动交易" + lock_wallet: "锁定钱包" + switch_wallet: "切换钱包" + advanced: "高级" + privacy: "隐私" + mixnet_routing: "mixnet 路由" + messages_lookups: "消息和查询" + auto_accept: "自动接受" + pairing: "配对" + accept_anyone: "任何人" + accept_contacts: "仅联系人" + accept_ask: "每次询问" + requests: "请求" + incoming_requests: "收到的请求" + incoming_requests_sub: "允许他人向你请求付款" + appearance: "外观" + theme: "主题" + theme_light: "浅色" + theme_dark: "深色" + theme_yellow: "黄色" + archive: "存档" + export_archive: "导出存档" + wipe_history: "清除付款记录" + about: "关于" + goblin: "Goblin" + build: "构建 %{build}" + network: "网络" + network_value: "MW + Nym mixnet + nostr" + third_party: "第三方" + grim: "GRIM(上游钱包)" + grin_node: "Grin 节点" + sp_intro: "高级功能 — 像 GRIM 那样手动交换原始 slatepack。仅在无法通过 username 收付款时使用。" + sp_receive_group: "接收或确认" + sp_receive_blurb: "粘贴别人给你的 slatepack。Goblin 会接收付款、支付账单,或确认并广播到链上。" + sp_process: "处理 slatepack" + sp_paste_first: "请先粘贴 slatepack。" + sp_reply_ready: "回复已就绪 — 发回给发送方。" + sp_finalizing: "正在确认并广播到链上…" + sp_create_group: "创建付款" + sp_create_blurb: "生成一个 slatepack 交给他人。对方接收后将回复发回,你在上方确认即可。" + sp_amount_hint: "金额(grin)" + sp_addr_hint: "收款地址(可选)" + sp_create: "创建 slatepack" + sp_ready: "slatepack 已就绪 — 交给收款方。" + sp_amount_gt_zero: "请输入大于零的金额。" + sp_to_send: "待发送的 slatepack" + sp_copy: "复制 slatepack" + rotate_line1: "• 你会得到一个全新的随机密钥;旧 npub 将停止接收。两者之间没有任何派生关系。" + rotate_line2: "• 新密钥无法从助记词恢复 — 轮换后请立即备份新的 nsec。" + rotate_line3: "• 你的用户名将被释放——请立即认领相同或新的名称(一旦释放,他人也可抢注)。" + rotate_line4: "• 正在发往旧密钥的付款将受影响 — 请先等待待处理付款完成。" + rotate_line5: "• 直接保存了你 npub 的联系人需要重新查找你 — 分享你的新 npub 或重新注册的 username。" + cancel: "取消" + continue: "继续" + final_confirmation: "最终确认" + rotate_confirm_blurb: "此操作在应用内无法撤销。输入 RESET 并输入钱包密码以进行轮换。" + type_reset: "输入 RESET" + wallet_password: "钱包密码" + rotate_key_btn: "轮换密钥" + rotating_key: "正在轮换密钥…" + key_rotated: "密钥已轮换" + new_npub: "新 npub:%{npub}" + backup_new_key: "立即备份新私钥 — 助记词无法恢复它。" + copy_new_nsec: "复制新 nsec 备份" + done: "完成" + rotation_failed: "轮换失败" + close: "关闭" + import_identity_title: "导入身份" + import_blurb: "替换此钱包的 nostr 身份——选择一个 GOBLIN .backup 文件,或粘贴 nsec。备份也会恢复你的用户名和历史。如果仍需要当前密钥,请先备份。" + import_nsec_hint: "nsec1… 或粘贴的备份" + backup_password_hint: "备份密码(仅当在他处导出时需要)" + import_btn: "导入" + importing: "正在导入…" + identity_replaced: "身份已替换" + now_using: "当前使用:%{npub}" + import_failed: "导入失败" + name_authority: "名称授权方" + name_authority_title: "更改名称授权方" + name_authority_blurb: "注册和验证名称的服务器。指向另一个实例即可使用并支付那里托管的名称。" + name_authority_invalid: "请输入完整网址(https://…)。" + reset: "重置" + save: "保存" + backup_file: "备份到文件" + choose_backup_file: "选择 .backup 文件" + backup_read_failed: "无法读取该文件。" + backup_saved: "备份已保存" + backup_saved_sub: "妥善保管 .backup 文件——同时拥有它和你密码的人都能恢复你的身份。" + backup_file_title: "备份身份" + backup_file_blurb: "创建一个包含你的用户名和密钥的加密 .backup 文件。输入钱包密码以封存它。" + backup_write_failed: "无法保存文件。" + create_backup: "创建备份" + registered: "已注册 %{name}" + released_msg: "已释放 — 用户名可被抢注" + release_confirm: "释放 %{name}?" + release_blurb: "一旦释放即可被认领——任何人都可以,包括你接下来轮换到的密钥。10 分钟内你无法注册另一个用户名。" + releasing: "正在释放…" + keep_it: "保留" + release_it: "释放" + username: "用户名" + username_note: "显示为 you。在 goblin.st 上公开。付款保持加密。" + release_username: "释放用户名" + pick_username: "选择用户名 — 可选" + working: "处理中…" + claim: "注册" + err_just_taken: "该用户名刚被占用" + err_cooldown: "你刚释放了一个用户名 — 10 分钟内无法注册新用户名。" + err_unreachable: "无法连接 goblin.st — 连接中断。请重试。" + err_release: "无法释放:%{err}" + avail_available: "可用!" + avail_taken: "已被占用" + avail_reserved: "已保留" + avail_invalid: "用户名为 3–20 个字符:a–z、0–9、_ 或 -" + avail_quarantined: "不可用" + avail_unknown: "无法检查 — 连接中断。请重试。" + advanced: + title: "高级" + intro: "来自 GRIM 的底层钱包工具。通常你用不到这些。" + own_node_desc: "在本设备上同步完整的 Grin 节点,而不是信任公共节点。" + own_node_active: "正在运行你自己的节点" + repair: "修复钱包" + repair_desc: "重新扫描链并恢复任何缺失的输出。这可能需要一些时间。" + repair_unavailable: "需要先有已同步的节点连接。" + repairing: "修复中… %{pct}%" + restore: "恢复钱包" + restore_desc: "删除本地数据并从助记词重建。如果修复无效,请使用此功能 — 之后需重新打开钱包。" + restore_confirm: "再次点击以恢复" + show_phrase: "恢复助记词" + phrase_desc: "你的 24 个 grin 助记词 — 恢复资金的唯一方式。请离线且私密保存。" + reveal: "显示助记词" + hide: "隐藏" + password: "钱包密码" + wrong_password: "密码错误。" + delete: "删除钱包" + delete_desc: "从此设备永久移除该钱包。没有助记词,资金将无法找回。" + delete_confirm: "再次点击以删除" + manage_node: "管理节点连接" + repair_confirm: "是的,立即修复" + repair_confirm_note: "修复会重新扫描链,可能需要几分钟。" + restore_confirm_note: "这会清除本地数据并从助记词重建——可能需要几分钟。" + privacy: + title: "网络隐私" + intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。" + payments: "付款" + payments_blurb: "每条携带 slatepack 的 nostr 消息。" + usernames: "用户名" + usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。" + price_avatars: "价格" + price_avatars_blurb: "金额旁显示的实时法币汇率。" + over_mixnet: "经由 mixnet" + direct_connection: "直接连接" + grin_node: "Grin 节点" + grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。" + pairing: + title: "配对" + intro: "你的余额和金额以何种货币显示。" + pair_with: "配对货币" + rates_note: "汇率仅在开启配对时通过 Nym mixnet 获取 — 关闭后不会有任何汇率请求离开你的设备。" + relays: + title: "中继" + intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。" + your_relays: "你的中继" + add_relay: "添加中继" + add_relay_btn: "添加中继" + save_reconnect: "保存并重新连接" + none: "无" + count: "%{n} 个中继" + node: + title: "节点" + connection: "连接" + integrated: "集成节点" + applies_after: "在钱包锁定并再次解锁后生效。" + add_external: "添加外部节点" + api_secret_hint: "API 密钥(可选)" + add_node: "添加节点" + integrated_host: "集成节点" + summary_syncing: "%{conn} · 同步中" + summary_block: "区块 %{height} · %{conn}" + nips: + title: "nostr 与 NIPs" + intro1: "Goblin 使用 nostr — 一种通过简单中继服务器传递签名消息的开放协议。你的钱包拥有自己的 nostr 身份:一个独立的随机密钥,刻意与你的资金和助记词保持独立。每笔付款都作为身份之间的端到端加密私信传输,slatepack 就包含在其中。" + intro2: "goblin.st 是 Goblin 的名称服务:注册用户名会在此发布名称 → 密钥的映射(NIP-05),让人们可以付款给 you 而不必使用冗长的 npub。用户名是公开的;付款内容则永不公开。NIPs 是该协议的构建模块 — 点击任意一项可阅读规范。" + n05_title: "名称" + n05_blurb: "将 username@goblin.st 映射到你的密钥,让用户名像地址一样使用。" + n17_title: "私密消息" + n17_blurb: "每笔付款传输所用的加密私信信封。" + n44_title: "加密" + n44_blurb: "这些消息内部使用的认证加密算法。" + n49_title: "密钥加密" + n49_blurb: "私钥静态存储的方式,由你的密码锁定。" + n59_title: "礼物包装" + n59_blurb: "包装消息,使中继无法看到通信双方是谁。" + n98_title: "HTTP 认证" + n98_blurb: "为向 goblin.st 注册用户名的请求签名。" + onboarding: + intro: + private_money_head: "私密货币" + private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。" + send_like_message_head: "像发消息一样付款" + send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Nym mixnet 送达 — 中间任何人都看不到金额或参与者。" + yours_alone_head: "完全属于你" + yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。" + get_started: "开始使用" + footnote: "约需一分钟。之后一切均可更改。" + node: + kicker: "步骤 1 / 3 · 网络" + title: "Goblin 该如何\n监视链?" + own_title: "运行我自己的节点" + own_badge: "私密" + own_body: "无需信任任何人 — 钱包自行验证链。完成设置时在后台同步。" + connect_title: "连接到节点" + connect_badge: "即时" + connect_body: "无需等待同步。你选择的节点可看到钱包的查询。" + changeable: "随时可在 设置 → 节点 中更改。" + continue: "继续" + url_invalid: "节点 URL 必须以 http:// 或 https:// 开头" + wallet: + kicker: "步骤 2 / 3 · 钱包" + title: "设置你的钱包" + create_new: "新建" + restore_from_seed: "从助记词恢复" + name_hint: "钱包名称" + password_hint: "密码" + repeat_password_hint: "重复密码" + restore_hint: "准备好你的助记词 — 下一步将输入。" + create_hint: "接下来你会得到 24 个助记词以供抄写。它们就是钱 — 谁持有它们,谁就掌握你的资金。" + continue: "继续" + passwords_no_match: "密码不一致" + words: + kicker: "步骤 2 / 3 · 钱包" + title_restore: "输入你的助记词" + title_create: "抄下这些词" + write_down_hint: "按顺序写在纸上。任何持有这些词的人都能取走你的资金;丢失这些词又丢失设备,资金将无法找回。" + paste: "粘贴" + scan_qr: "扫描二维码" + copy_clipboard: "复制到剪贴板(不建议)" + restore_wallet: "恢复钱包" + wrote_them_down: "我已抄好" + fill_every_word: "填写每个词 — 点击某个词进行编辑,或粘贴整个短语。" + confirm: + kicker: "步骤 2 / 3 · 钱包" + title: "现在来验证" + enter_hint: "输入你刚抄下的词。点击某个词进行输入。" + paste: "粘贴" + create_wallet: "创建钱包" + keep_going: "继续 — 每个词,按顺序。" + identity: + kicker: "步骤 3 / 3 · 身份" + title: "你的付款身份" + key_being_made: "正在生成密钥…" + connected_nym: "已通过 Nym 连接" + connecting_nym: "正在通过 Nym 连接…" + fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。" + clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。" + pick_username: "选择用户名 — 可选" + username_blurb: "朋友支付给你的名称,而不是一长串密钥。可选——随时认领。" + username_field_hint: "你的用户名" + working: "处理中…" + claim_username: "注册用户名" + available_when_connected: "mixnet 连接后可用 — 或跳过,稍后注册。" + youre: "你是 %{name}" + claimed_title: "%{name} 已归你所有" + claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。" + open_wallet: "打开我的钱包" + skip_for_now: "暂时跳过" + import_existing: "已有 Goblin 身份?导入它" + import_title: "导入你的身份" + import_blurb: "粘贴你的 nsec 或选择一个 .backup 文件,保留你现有的密钥和用户名,而不是这个新的。" + errors: + cant_open: "无法打开钱包:%{err}" + cant_create: "无法创建钱包:%{err}" + send: + scan_to_request: "扫码请求" + scan_to_pay: "扫码支付" + tab_scan: "扫描" + tab_my_code: "我的二维码" + request_from: "向谁请求" + send_to: "发送给" + search_hint: "handle、npub 或名称" + suggested: "%{icon} 建议" + no_contacts: "暂无联系人。通过 handle 查找某人。" + no_profile: "无资料" + tag_contact: "联系人" + tag_on_nostr: "在 nostr 上" + searching_nostr: "正在搜索 nostr…" + unverified_title: "向未验证的密钥付款?" + unverified_body: "此密钥未发布 nostr 资料 — 它可能是全新的、匿名的或输错的。发送前请仔细核对是否正确。" + keep_looking: "继续查找" + pay_anyway: "仍然付款" + scan_not_recipient: "该二维码不是 goblin 收款方 — 应为 npub 或 handle" + scan_prompt: "将 goblin 二维码对准取景框以激活" + scan_to_pay_me: "扫码向我付款" + share_btn: "%{icon} 分享" + share_message: "在 Goblin 上向我付款 — %{handle}\n%{link}\nnpub:%{npub}" + none_found: "未找到与 %{label} 匹配的人" + enter_recipient: "输入 handle、npub 或名称" + amount_title: "金额" + to_name: "发送给 %{name}" + not_enough: "你的 grin 余额不足" + max: "最大" + note_label: "备注" + note_hint: "添加备注…" + add_note: "添加备注" + edit_note: "编辑备注" + note_cancel: "取消" + note_save: "保存" + review_btn: "查看" + confirm_request: "确认请求" + review_title: "查看" + requesting_from: "向 %{name} 请求" + youre_sending: "你正在发送给 %{name}" + row_from: "付款方" + row_to: "收款方" + row_note: "备注" + row_they_pay: "对方支付" + row_they_pay_val: "仅当对方同意时" + row_delivery: "传输" + row_delivery_val: "NIP-44 加密,经由 Nym" + row_network_fee: "网络费用" + row_network_fee_val: "从你的余额中扣除" + row_privacy: "隐私" + row_privacy_val: "Mimblewimble + Nym" + send_request_btn: "发送请求" + request_approve_hint: "对方将收到一条待同意的请求" + hold_to_send: "长按发送" + lower_amount: "返回并降低金额" + hold_confirm_hint: "按住以确认" + requesting: "正在请求…" + sending: "正在发送…" + they: "对方" + request_blocked: "%{who} 不接受请求。请对方改为向你发送 grin。" + failed_request_title: "请求失败" + failed_send_title: "发送失败" + failed_request_body: "我们无法送达请求。请对方改为向你发送 grin。" + failed_send_body: "付款未送达。你的 grin 是安全的 — 请重试。" + try_again_btn: "重试" + close_btn: "关闭" + success: + requested: "已请求" + sent: "已发送" + from: "来自" + to: "发往" + subtitle: "%{dir} %{who} · 刚刚" + done_btn: "完成" + receipt_btn: "收据" \ No newline at end of file diff --git a/macos/Goblin.app/Contents/Info.plist b/macos/Goblin.app/Contents/Info.plist new file mode 100644 index 00000000..8a6776b0 --- /dev/null +++ b/macos/Goblin.app/Contents/Info.plist @@ -0,0 +1,65 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Goblin + CFBundleExecutable + goblin + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + mw.gri.macos + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Goblin + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.3.6 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + NSCameraUsageDescription + Goblin needs an access to your camera to scan QR code. + CFBundleDocumentTypes + + + CFBundleTypeName + Apple SimpleText document + CFBundleTypeRole + Viewer + LSItemContentTypes + + com.apple.traditional-mac-plain-text + + NSDocumentClass + Document + + + CFBundleTypeName + Unknown document + CFBundleTypeRole + Viewer + LSItemContentTypes + + public.data + + NSDocumentClass + Document + + + LSApplicationCategoryType + public.app-category.finance + NSHumanReadableCopyright + 2024 + + diff --git a/macos/Goblin.app/Contents/MacOS/.gitignore b/macos/Goblin.app/Contents/MacOS/.gitignore new file mode 100644 index 00000000..075ed116 --- /dev/null +++ b/macos/Goblin.app/Contents/MacOS/.gitignore @@ -0,0 +1,2 @@ +!.gitignore +grim \ No newline at end of file diff --git a/macos/Goblin.app/Contents/Resources/AppIcon.icns b/macos/Goblin.app/Contents/Resources/AppIcon.icns new file mode 100644 index 00000000..4677c671 Binary files /dev/null and b/macos/Goblin.app/Contents/Resources/AppIcon.icns differ diff --git a/macos/build_release.sh b/macos/build_release.sh new file mode 100755 index 00000000..c6e94d77 --- /dev/null +++ b/macos/build_release.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +case $1 in + x86_64|arm|universal) + ;; + *) + echo "Usage: release_macos.sh [platform] [version]\n - platform: 'x86_64', 'arm', 'universal'" >&2 + exit 1 +esac + +if [[ "$OSTYPE" != "darwin"* ]]; then + if [ -z ${SDKROOT+x} ]; then + echo "MacOS SDKROOT is not set" + exit 1 + else + echo "Use MacOS SDK: ${SDKROOT}" + fi +fi + +# Setup build directory +BASEDIR=$(cd $(dirname $0) && pwd) +cd ${BASEDIR} +cd .. + +# Setup platform +[[ $1 == "x86_64" ]] && arch+=(x86_64-apple-darwin) +[[ $1 == "arm" ]] && arch+=(aarch64-apple-darwin) + +rustup target add x86_64-apple-darwin +rustup target add aarch64-apple-darwin +[[ $1 == "universal" ]]; arch+=(universal2-apple-darwin) +cargo install cargo-zigbuild +cargo zigbuild --release --target ${arch} + +rm -f .intentionally-empty-file.o + +yes | cp -rf target/${arch}/release/goblin macos/Goblin.app/Contents/MacOS + +# Sign .app resources on change: +#rcodesign generate-self-signed-certificate +#rcodesign sign --pem-file cert.pem macos/Goblin.app + +# Create release package +FILE_NAME=goblin-v$2-macos-$1.zip +cd macos +zip -r ${FILE_NAME} Goblin.app +mv ${FILE_NAME} ../target/${arch}/release diff --git a/node b/node new file mode 160000 index 00000000..bce5a714 --- /dev/null +++ b/node @@ -0,0 +1 @@ +Subproject commit bce5a7144be4085dc9f4bcbb66d860ffeef9b4ca diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..7dbfd362 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +hard_tabs = true +edition = "2024" diff --git a/scripts/android.sh b/scripts/android.sh new file mode 100755 index 00000000..5533e2cf --- /dev/null +++ b/scripts/android.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +usage="Usage: android.sh [type] [platform|version] [flavor]\n - type: 'build' to run locally, 'lib' - .so for all platforms, 'release' - .apk for all platforms\n - platform, for 'build' type: 'v7', 'v8', 'x86'\n - version for 'lib' and 'release', example: '0.2.2'\n - optional flavor, for non-'lib' type: 'ci' for local maven, default - 'local' for external" +case $1 in + build|lib|release) + ;; + *) + printf "$usage" + exit 1 +esac + +if [[ $1 == "build" ]]; then + case $2 in + v7|v8|x86) + ;; + *) + printf "$usage" + exit 1 + esac +fi + +# Setup build directory +BASEDIR=$(cd "$(dirname "$0")" && pwd) +cd "${BASEDIR}" || exit 1 +cd .. + +# Prefer the GRIM-canonical toolchain: the custom NDK r29 (rebuilt LLVM, 16 KB +# page-aligned) + Android SDK from code.gri.mw/DEV. scripts/toolchain.sh fetches +# them and writes this env; falls back to whatever NDK/SDK is on the system. +[ -f .toolchains/env.sh ] && source .toolchains/env.sh + +# Install platforms and tools +rustup target add armv7-linux-androideabi +rustup target add aarch64-linux-android +rustup target add x86_64-linux-android +cargo install cargo-ndk + +success=1 + +### Build native code +function build_lib() { + [[ $1 == "v7" ]] && arch=armeabi-v7a + [[ $1 == "v8" ]] && arch=arm64-v8a + [[ $1 == "x86" ]] && arch=x86_64 + + sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml + sed -i -e 's/"rlib"]/"cdylib","rlib"]/g' Cargo.toml + + cargo ndk -t "${arch}" -o android/app/src/main/jniLibs build --profile release-apk + if [ $? -ne 0 ]; then + success=0 + fi + + sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml + rm -f Cargo.toml-e + + # The Nym mixnet is linked INTO libgrim.so (nym-sdk is a regular dependency), + # so there is no separate sidecar binary to cross-build or bundle into jniLibs. +} + +### Build application +function build_apk() { + flavor=$3 + [[ -z "$flavor" ]] && flavor="local" + cd android || exit 1 + ./gradlew clean + # Build signed apk if keystore exists + if [ ! -f keystore.properties ]; then + ./gradlew assemble${flavor}Debug + if [ $? -ne 0 ]; then + success=0 + fi + apk_path=app/build/outputs/apk/${flavor}/debug/app-${flavor}-debug.apk + else + ./gradlew assemble${flavor}SignedRelease + if [ $? -ne 0 ]; then + success=0 + fi + apk_path=app/build/outputs/apk/${flavor}/signedRelease/app-${flavor}-signedRelease.apk + fi + + if [[ $1 == "" ]] && [ $success -eq 1 ]; then + # Launch application at all connected devices. + for SERIAL in $(adb devices | grep -v List | cut -f 1); + do + adb -s "$SERIAL" install ${apk_path} + sleep 1s + adb -s "$SERIAL" shell am start -n mw.gri.android/.MainActivity; + done + elif [ $success -eq 1 ]; then + # Get version + version=$2 + if [[ -z "$version" ]]; then + version=v$(grep -m 1 -Po 'version = "\K[^"]*' ../Cargo.toml) + fi + # Setup release file name + name=grim-${version}-android-$1.apk + [[ $1 == "arm" ]] && name=grim-${version}-android.apk + rm -f "${name}" + mv ${apk_path} "${name}" + # Calculate checksum + checksum=grim-${version}-android-$1-sha256sum.txt + [[ $1 == "arm" ]] && checksum=grim-${version}-android-sha256sum.txt + rm -f "${checksum}" + sha256sum "${name}" > "${checksum}" + fi + + cd .. +} + +rm -rf android/app/src/main/jniLibs/* +if [[ $1 == "lib" ]]; then + build_lib "v7" + [ $success -eq 1 ] && build_lib "v8" + [ $success -eq 1 ] && build_lib "x86" + [ $success -eq 1 ] && exit 0 +elif [[ $1 == "build" ]]; then + build_lib "$2" + [ $success -eq 1 ] && build_apk "" "" "$3" + [ $success -eq 1 ] && exit 0 +else + rm -rf target/release-apk + rm -rf target/aarch64-linux-android + rm -rf target/x86_64-linux-android + rm -rf target/armv7-linux-androideabi + build_lib "v7" + [ $success -eq 1 ] && build_lib "v8" + [ $success -eq 1 ] && build_apk "arm" "$2" "$3" + rm -rf android/app/src/main/jniLibs/* + [ $success -eq 1 ] && build_lib "x86" + [ $success -eq 1 ] && build_apk "x86_64" "$2" "$3" + [ $success -eq 1 ] && exit 0 +fi + +exit 1 \ No newline at end of file diff --git a/scripts/desktop.sh b/scripts/desktop.sh new file mode 100755 index 00000000..00e79522 --- /dev/null +++ b/scripts/desktop.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +case $1 in + debug|build) + ;; + *) + echo "Usage: build_run.sh [type] where is type is 'debug' or 'build'" >&2 + exit 1 +esac + +# Setup build directory +BASEDIR=$(cd "$(dirname $0)" && pwd) +cd "${BASEDIR}" || return +cd .. + +# Build application +type=$1 +[[ ${type} == "build" ]] && release_param+=(--release) +cargo --config profile.release.incremental=true build "${release_param[@]}" + +# Start application +if [ $? -eq 0 ] +then + path=${type} + [[ ${type} == "build" ]] && path="release" + ./target/"${path}"/grim +fi diff --git a/scripts/gen_icons.sh b/scripts/gen_icons.sh new file mode 100755 index 00000000..8ddbe079 --- /dev/null +++ b/scripts/gen_icons.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Regenerate EVERY platform's app icon from two canonical sources: +# img/goblin-icon.png the gradient app icon (yellow gradient + black mascot) +# img/goblin-mark-black.svg the black mascot mark, vector, transparent bg +# (mirror of site/assets/goblin-mark-black.svg) +# +# Square icons (desktop window, Linux AppImage, Android launcher, Windows .ico, +# macOS .icns) come from the gradient PNG. The Android *adaptive* foreground is +# the black mascot on transparency, composited by the OS over the yellow +# background color (res/values/ic_launcher_background.xml = #FFD60A) — which +# reproduces the gradient icon's look. +# +# Requires ImageMagick (magick) and python3 (for the .icns container). +set -euo pipefail +cd "$(dirname "$0")/.." + +ICON=img/goblin-icon.png +MARK=img/goblin-mark-black.svg +RES=android/app/src/main/res + +# --- Desktop window icon (egui, src/main.rs) + Linux AppImage AppDir icon --- +magick "$ICON" -resize 256x256 PNG32:img/icon.png +cp img/icon.png linux/Goblin.AppDir/goblin.png + +# --- Android launcher icons (gradient square) + adaptive foreground (mascot) --- +declare -A SIZES=( [mdpi]=48 [hdpi]=72 [xhdpi]=96 [xxhdpi]=144 [xxxhdpi]=192 ) +declare -A FG_SIZES=( [mdpi]=108 [hdpi]=162 [xhdpi]=216 [xxhdpi]=324 [xxxhdpi]=432 ) +for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do + s=${SIZES[$d]}; fg=${FG_SIZES[$d]} + # Mascot fills ~60% of the adaptive canvas — inside the ~61% safe zone, so no + # launcher mask (circle/squircle) ever clips it. + art=$(( fg * 60 / 100 )) + magick "$ICON" -resize "${s}x${s}" PNG32:"$RES/mipmap-$d/ic_launcher.png" + magick "$ICON" -resize "${s}x${s}" PNG32:"$RES/mipmap-$d/ic_launcher_round.png" + magick -background none "$MARK" -resize "${art}x${art}" \ + -gravity center -extent "${fg}x${fg}" PNG32:"$RES/mipmap-$d/ic_launcher_foreground.png" +done + +# --- Windows installer + file-type icon (WiX wix/Product.ico) --- +magick "$ICON" -define icon:auto-resize=256,128,64,48,32,24,16 wix/Product.ico + +# --- macOS app bundle icon (Goblin.app) --- +python3 scripts/make-icns.py "$ICON" macos/Goblin.app/Contents/Resources/AppIcon.icns + +echo "icons generated from $ICON + $MARK" diff --git a/scripts/make-icns.py b/scripts/make-icns.py new file mode 100755 index 00000000..205b05a7 --- /dev/null +++ b/scripts/make-icns.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Build a macOS .icns from a square PNG, dependency-free. + +macOS has `iconutil` and Linux distros have `png2icns`, but neither is reliably +present, and ImageMagick's own .icns writer only emits a single size. So we +assemble the multi-resolution PNG-payload .icns container by hand (the format +macOS 10.7+ accepts): the `icns` magic + big-endian length, then one entry per +OSType, each carrying an 8-bit PNG. ImageMagick (`magick`) does the resizing. + +Usage: make-icns.py +""" + +import struct +import subprocess +import sys + +# OSType -> pixel size. PNG-payload entries. Sizes above the source are +# Lanczos-upscaled (soft but acceptable for the few large Dock/Finder slots). +SLOTS = [ + (b"icp4", 16), + (b"icp5", 32), + (b"icp6", 64), + (b"ic07", 128), + (b"ic08", 256), + (b"ic11", 32), # 16@2x + (b"ic12", 64), # 32@2x + (b"ic13", 256), # 128@2x + (b"ic09", 512), # 512 + (b"ic14", 512), # 256@2x +] + + +def render(src, size): + out = "/tmp/_icns_%d.png" % size + subprocess.run( + ["magick", src, "-resize", "%dx%d" % (size, size), + "-filter", "Lanczos", "-depth", "8", "PNG32:%s" % out], + check=True, + ) + return open(out, "rb").read() + + +def main(): + if len(sys.argv) != 3: + sys.exit("usage: make-icns.py ") + src, out = sys.argv[1], sys.argv[2] + cache, entries = {}, [] + for ostype, size in SLOTS: + if size not in cache: + cache[size] = render(src, size) + data = cache[size] + entries.append(ostype + struct.pack(">I", 8 + len(data)) + data) + body = b"".join(entries) + with open(out, "wb") as f: + f.write(b"icns" + struct.pack(">I", 8 + len(body)) + body) + print("wrote %s (%d entries)" % (out, len(entries))) + + +if __name__ == "__main__": + main() diff --git a/scripts/toolchain.sh b/scripts/toolchain.sh new file mode 100755 index 00000000..d3fe6403 --- /dev/null +++ b/scripts/toolchain.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# +# Fetch the canonical GRIM build toolchains (code.gri.mw/DEV) into .toolchains/. +# +# These mirror exactly what upstream GRIM's CI uses, so Goblin cross-builds every +# platform from one Linux box the same way GRIM does — instead of relying on +# whatever NDK/zig/appimagetool happens to be installed on the machine. +# +# Idempotent: each tool is skipped if already present. Linux x86_64 host only +# (the box we build releases on). Writes .toolchains/env.sh with the exports the +# build scripts source; run nothing else by hand. +# +# Usage: +# scripts/toolchain.sh # core: ndk zig appimage (what desktop+android need) +# scripts/toolchain.sh sdk gradle # add the Android SDK + Gradle +# scripts/toolchain.sh osxcross # build the macOS cross-toolchain (heavy) +# scripts/toolchain.sh all # everything +# +set -euo pipefail + +BASEDIR=$(cd "$(dirname "$0")/.." && pwd) +TC="${BASEDIR}/.toolchains" +DEV="https://code.gri.mw/DEV" +mkdir -p "${TC}" + +dl() { echo " ↓ $(basename "$2")"; curl -fSL --retry 3 -o "$2" "$1"; } + +# Pinned versions — bump here to track GRIM's DEV releases. +NDK_TAG="r29"; NDK_ARCHIVE="android-ndk-${NDK_TAG}-x86_64-linux-musl.tar.xz"; NDK_DIR="${TC}/android-ndk-${NDK_TAG}" +ZIG_VER="0.12.1"; ZIG_DIR="${TC}/zig" +AT_VER="1.9.1"; RT_TAG="20251108" +SDK_TAG="r36"; SDK_DIR="${TC}/android-sdk" +GRADLE_VER="8.13"; GRADLE_DIR="${TC}/gradle-${GRADLE_VER}" +SDK_VER="12.3"; OSX_DIR="${TC}/osxcross" + +fetch_ndk() { + [ -e "${NDK_DIR}/source.properties" ] && { echo "ndk r29: present"; return; } + echo "ndk: fetching custom NDK ${NDK_TAG} (rebuilt LLVM, 16 KB-aligned)…" + dl "${DEV}/android-ndk-custom/releases/download/${NDK_TAG}/${NDK_ARCHIVE}" "${TC}/ndk.tar.xz" + tar -xJf "${TC}/ndk.tar.xz" -C "${TC}"; rm -f "${TC}/ndk.tar.xz" +} + +fetch_zig() { + [ -x "${ZIG_DIR}/zig" ] && { echo "zig ${ZIG_VER}: present"; return; } + echo "zig: fetching ${ZIG_VER} (linker for cargo-zigbuild)…" + dl "${DEV}/zig/releases/download/${ZIG_VER}/zig-linux-x86_64-${ZIG_VER}.tar.xz" "${TC}/zig.tar.xz" + tar -xJf "${TC}/zig.tar.xz" -C "${TC}"; rm -f "${TC}/zig.tar.xz" + rm -rf "${ZIG_DIR}"; mv "${TC}/zig-linux-x86_64-${ZIG_VER}" "${ZIG_DIR}" +} + +fetch_appimage() { + [ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && { echo "appimagetool ${AT_VER}: present"; return; } + echo "appimage: fetching appimagetool ${AT_VER} + type2 runtime…" + dl "${DEV}/appimagetool/releases/download/${AT_VER}/appimagetool-x86_64.AppImage" "${TC}/appimagetool" + dl "${DEV}/appimage-type2-runtime/releases/download/${RT_TAG}/runtime-x86_64" "${TC}/runtime-x86_64" + chmod +x "${TC}/appimagetool" "${TC}/runtime-x86_64" +} + +# Assemble a minimal Android SDK (build-tools + platform + platform-tools) from +# the DEV mirror so gradle has an SDK without a system install. +fetch_sdk() { + [ -d "${SDK_DIR}/platform-tools" ] && { echo "android-sdk ${SDK_TAG}: present"; return; } + echo "android-sdk: fetching build-tools + platform-36 + platform-tools (${SDK_TAG})…" + local base="${DEV}/android-platform-tools/releases/download/${SDK_TAG}" + mkdir -p "${SDK_DIR}/build-tools" "${SDK_DIR}/platforms" + dl "${base}/build-tools_r36.1_linux.zip" "${TC}/bt.zip" + dl "${base}/platform-36_r02.zip" "${TC}/pf.zip" + dl "${base}/platform-tools_r36.0.0-linux.zip" "${TC}/pt.zip" + # build-tools zip unzips to android-NN/ → rename to the version dir gradle wants. + local tmp; tmp=$(mktemp -d) + unzip -q "${TC}/bt.zip" -d "${tmp}"; mv "${tmp}"/*/ "${SDK_DIR}/build-tools/36.1.0" + unzip -q "${TC}/pf.zip" -d "${tmp}"; mv "${tmp}"/*/ "${SDK_DIR}/platforms/android-36" + unzip -q "${TC}/pt.zip" -d "${SDK_DIR}" + rm -rf "${tmp}" "${TC}/bt.zip" "${TC}/pf.zip" "${TC}/pt.zip" +} + +fetch_gradle() { + [ -x "${GRADLE_DIR}/bin/gradle" ] && { echo "gradle ${GRADLE_VER}: present"; return; } + echo "gradle: fetching ${GRADLE_VER}…" + dl "${DEV}/gradle/releases/download/v${GRADLE_VER}/gradle-${GRADLE_VER}-bin.zip" "${TC}/gradle.zip" + unzip -q "${TC}/gradle.zip" -d "${TC}"; rm -f "${TC}/gradle.zip" +} + +# osxcross: build the macOS cross-toolchain from source + the DEV macOS SDK. +# Heavy (compiles cctools/ld64); enables building macOS binaries off-Mac. CI also +# builds macOS natively, so this is the local/offline path — experimental. +fetch_osxcross() { + [ -x "${OSX_DIR}/target/bin/o64-clang" ] && { echo "osxcross: present"; return; } + command -v clang >/dev/null || { echo "osxcross: needs system clang/cmake — skipping"; return; } + echo "osxcross: cloning + building with macOS SDK ${SDK_VER} (slow)…" + [ -d "${OSX_DIR}/.git" ] || git clone --depth 1 "${DEV}/osxcross" "${OSX_DIR}" + dl "${DEV}/macosx-sdks/releases/download/${SDK_VER}/MacOSX${SDK_VER}.sdk.tar.xz" "${OSX_DIR}/tarballs/MacOSX${SDK_VER}.sdk.tar.xz" + ( cd "${OSX_DIR}" && UNATTENDED=1 ./build.sh ) +} + +write_env() { + { + echo "# Auto-generated by scripts/toolchain.sh — source me for GRIM-canonical builds." + [ -e "${NDK_DIR}/source.properties" ] && { echo "export ANDROID_NDK_HOME=\"${NDK_DIR}\""; echo "export ANDROID_NDK_ROOT=\"${NDK_DIR}\""; } + [ -d "${SDK_DIR}/platform-tools" ] && echo "export ANDROID_HOME=\"${SDK_DIR}\"" + local p="${TC}" + [ -x "${ZIG_DIR}/zig" ] && p="${ZIG_DIR}:${p}" + [ -x "${GRADLE_DIR}/bin/gradle" ] && p="${GRADLE_DIR}/bin:${p}" + [ -x "${OSX_DIR}/target/bin/o64-clang" ] && p="${OSX_DIR}/target/bin:${p}" + echo "export PATH=\"${p}:\$PATH\"" + echo "export GOBLIN_APPIMAGETOOL=\"${TC}/appimagetool\"" + echo "export GOBLIN_APPIMAGE_RUNTIME=\"${TC}/runtime-x86_64\"" + } > "${TC}/env.sh" +} + +tools=("$@"); [ ${#tools[@]} -eq 0 ] && tools=(ndk zig appimage) +[ "${tools[0]:-}" = "all" ] && tools=(ndk zig appimage sdk gradle osxcross) +for t in "${tools[@]}"; do + case "$t" in + ndk) fetch_ndk ;; + zig) fetch_zig ;; + appimage) fetch_appimage ;; + sdk) fetch_sdk ;; + gradle) fetch_gradle ;; + osxcross) fetch_osxcross ;; + *) echo "unknown tool: $t (ndk|zig|appimage|sdk|gradle|osxcross|all)" >&2; exit 1 ;; + esac +done +write_env +echo "toolchain ready → ${TC}/env.sh" diff --git a/scripts/version.sh b/scripts/version.sh new file mode 100755 index 00000000..7d81e23f --- /dev/null +++ b/scripts/version.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# Usage to bump version +# ./version.sh patch +# ./version.sh minor +# ./version.sh major + +# Setup base directory +BASEDIR=$(cd $(dirname $0) && pwd) +cd ${BASEDIR} +cd .. + +# Exit script if command fails or uninitialized variables used +set -euo pipefail + +# ================================== +# Verify repo is clean +# ================================== + +# List uncommitted changes and +# check if the output is not empty +if [ -n "$(git status --porcelain)" ]; then + # Print error message + printf "\nError: repo has uncommitted changes\n\n" + # Exit with error code + exit 1 +fi + +# ================================== +# Get latest version from git tags +# ================================== + +# List git tags sorted lexicographically +# so version numbers sorted correctly +GIT_TAGS=$(git tag --sort=version:refname) + +# Get last line of output which returns the +# last tag (most recent version) +GIT_TAG_LATEST=$(echo "$GIT_TAGS" | tail -n 1) + +# If no tag found, default to v0.1.0 +if [ -z "$GIT_TAG_LATEST" ]; then + GIT_TAG_LATEST="v0.1.0" +fi + +# Strip prefix 'v' from the tag to easily increment +GIT_TAG_LATEST=$(echo "$GIT_TAG_LATEST" | sed 's/^v//') + +# ================================== +# Increment version number +# ================================== + +# Get version type from first argument passed to script +VERSION_TYPE="${1-}" +VERSION_NEXT="" + +if [ "$VERSION_TYPE" = "patch" ]; then + # Increment patch version + VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$NF++; print $1"."$2"."$NF}')" +elif [ "$VERSION_TYPE" = "minor" ]; then + # Increment minor version + VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$2++; $3=0; print $1"."$2"."$3}')" +elif [ "$VERSION_TYPE" = "major" ]; then + # Increment major version + VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$1++; $2=0; $3=0; print $1"."$2"."$3}')" +else + # Print error for unknown versioning type + printf "\nError: invalid VERSION_TYPE arg passed, must be 'patch', 'minor' or 'major'\n\n" + # Exit with error code + exit 1 +fi + +# Update MacOS version. +sed -i '' -e 's/'"$GIT_TAG_LATEST"'/'"$VERSION_NEXT"'/' macos/Grim.app/Contents/Info.plist + +# Update version for Windows installer. +sed -i '' -e 's/" Version="[^\"]*"/" Version="'"$VERSION_NEXT"'"/g' wix/main.wxs +sed -i '' -e 's/ { + /// Handles platform-specific functionality. + pub platform: Platform, + + /// Main content. + content: Content, + + /// Last window resize direction. + resize_direction: Option, + /// Flag to check if it's first draw. + first_draw: bool, + /// Last status-bar icon state pushed to the platform (Android). + status_bar_white: Option, +} + +impl App { + pub fn new(platform: Platform) -> Self { + Self { + platform, + content: Content::default(), + resize_direction: None, + first_draw: true, + status_bar_white: None, + } + } + + /// Called of first content draw. + fn on_first_draw(&mut self, ctx: &Context) { + // Set platform context. + if View::is_desktop() { + self.platform.set_context(ctx); + } + // Setup visuals. + crate::setup_fonts(ctx); + crate::setup_visuals(ctx); + } + + /// Draw application content. + pub fn ui(&mut self, ctx: &Context) { + if self.first_draw { + self.on_first_draw(ctx); + self.first_draw = false; + } + + // Heartbeat for "is the app on-screen": stamp this frame, and keep a light + // ~2s repaint cadence so the stamp stays fresh while visible. When the app + // is backgrounded eframe stops calling this, the stamp goes stale, and + // background workers (the @name re-verify sweep) pause until we're back. + crate::mark_frame(); + ctx.request_repaint_after(std::time::Duration::from_secs(2)); + + // Keep the Android status-bar icons readable against the in-app theme + // (the app draws edge-to-edge over the status bar). Only on change. + let white_icons = crate::gui::theme::status_bar_white_icons(); + if self.status_bar_white != Some(white_icons) { + self.platform.set_status_bar_white_icons(white_icons); + self.status_bar_white = Some(white_icons); + } + + // Handle Esc keyboard key event and platform Back button key event. + let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed); + if back_pressed + || ctx.input_mut(|i| { + i.consume_key(Modifiers::NONE, egui::Key::Escape) + || i.consume_key(Modifiers::NONE, egui::Key::BrowserBack) + }) { + // Pass event to content. + self.content.on_back(ctx, &self.platform); + if back_pressed { + BACK_BUTTON_PRESSED.store(false, Ordering::Relaxed); + } + // Request repaint to update previous content. + ctx.request_repaint(); + } + + // Handle Close event on desktop. + if View::is_desktop() && ctx.input(|i| i.viewport().close_requested()) { + if !self.content.exit_allowed { + ctx.send_viewport_cmd(ViewportCommand::CancelClose); + Content::show_exit_modal(); + } else { + let (w, h) = View::window_size(ctx); + AppConfig::save_window_size(w, h); + ctx.input(|i| { + if let Some(rect) = i.viewport().outer_rect { + AppConfig::save_window_pos(rect.left(), rect.top()); + } + }); + } + } + + // Show main content. + egui::CentralPanel::default() + .frame(egui::Frame { + ..Default::default() + }) + .show(ctx, |ui| { + if View::is_desktop() { + let is_fullscreen = + ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false)); + let os = egui::os::OperatingSystem::from_target_os(); + match os { + egui::os::OperatingSystem::Mac => { + self.window_title_ui(ui, is_fullscreen); + ui.add_space(-1.0); + Self::title_panel_bg(ui, true); + self.content.ui(ui, &self.platform); + } + egui::os::OperatingSystem::Windows => { + Self::title_panel_bg(ui, false); + self.content.ui(ui, &self.platform); + } + _ => { + self.custom_frame_ui(ui, is_fullscreen); + } + } + } else { + Self::title_panel_bg(ui, false); + self.content.ui(ui, &self.platform); + } + }); + + // Check if desktop window was focused after requested attention. + if self.platform.user_attention_required() + && ctx.input(|i| i.viewport().focused.unwrap_or(true)) + { + self.platform.clear_user_attention(); + } + + // Show modal or keyboard window above opened Modal. + if Modal::opened().is_some() { + ctx.move_to_top(LayerId::new(Order::Middle, egui::Id::new(Modal::WINDOW_ID))); + let keyboard_showing = if let Some(l) = ctx.top_layer_id() { + l.id == egui::Id::new(KeyboardContent::WINDOW_ID) + } else { + false + }; + if keyboard_showing { + ctx.move_to_top(LayerId::new( + Order::Middle, + egui::Id::new(KeyboardContent::WINDOW_ID), + )); + } + } + // Reset keyboard state for newly opened modal. + if Modal::first_draw() { + KeyboardContent::reset_window_state(); + } + } + + /// Draw custom desktop window frame content. + fn custom_frame_ui(&mut self, ui: &mut egui::Ui, is_fullscreen: bool) { + // Paint the window area inside the frame margin with the theme + // background first: surface gaps are otherwise transparent, which X11 + // without a compositor renders as black (strip under the sidebar in + // light/yellow themes). The margin ring itself must STAY transparent — + // painting it gives the window a visible border under a compositor. + let fill_rect = if is_fullscreen { + ui.max_rect() + } else { + ui.max_rect().shrink(Content::WINDOW_FRAME_MARGIN) + }; + let fill_rounding = if is_fullscreen { + CornerRadius::ZERO + } else { + CornerRadius { + nw: 8, + ne: 8, + sw: 0, + se: 0, + } + }; + ui.painter() + .rect_filled(fill_rect, fill_rounding, Colors::fill()); + let content_bg_rect = { + let mut r = ui.max_rect(); + if !is_fullscreen { + r = r.shrink(Content::WINDOW_FRAME_MARGIN); + } + r.min.y += Content::WINDOW_TITLE_HEIGHT + TitlePanel::HEIGHT; + r + }; + let content_bg = RectShape::new( + content_bg_rect, + CornerRadius::ZERO, + Colors::fill_lite(), + View::default_stroke(), + StrokeKind::Outside, + ); + // Draw content background. + ui.painter().add(content_bg); + + let mut content_rect = ui.max_rect(); + if !is_fullscreen { + content_rect = content_rect.shrink(Content::WINDOW_FRAME_MARGIN); + } + // Draw window content. + ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| { + // Draw window title. + self.window_title_ui(ui, is_fullscreen); + ui.add_space(-1.0); + + // Draw title panel background. + Self::title_panel_bg(ui, true); + + let content_rect = { + let mut rect = ui.max_rect(); + rect.min.y += Content::WINDOW_TITLE_HEIGHT; + rect + }; + let mut content_ui = + ui.new_child(UiBuilder::new().max_rect(content_rect).layout(*ui.layout())); + // Draw main content. + self.content.ui(&mut content_ui, &self.platform); + }); + + // Setup resize areas. + if !is_fullscreen { + self.resize_area_ui(ui, ResizeDirection::North); + self.resize_area_ui(ui, ResizeDirection::East); + self.resize_area_ui(ui, ResizeDirection::South); + self.resize_area_ui(ui, ResizeDirection::West); + self.resize_area_ui(ui, ResizeDirection::NorthWest); + self.resize_area_ui(ui, ResizeDirection::NorthEast); + self.resize_area_ui(ui, ResizeDirection::SouthEast); + self.resize_area_ui(ui, ResizeDirection::SouthWest); + } + } + + /// Draw title panel background. + fn title_panel_bg(ui: &mut egui::Ui, window_title: bool) { + let title_rect = { + let mut rect = ui.max_rect(); + if window_title { + rect.min.y += Content::WINDOW_TITLE_HEIGHT - 0.5; + } + rect.max.y = rect.min.y + View::get_top_inset() + TitlePanel::HEIGHT; + rect + }; + let title_bg = RectShape::filled(title_rect, CornerRadius::ZERO, Colors::yellow()); + ui.painter().add(title_bg); + } + + /// Draw custom window title content. + fn window_title_ui(&self, ui: &mut egui::Ui, is_fullscreen: bool) { + let title_rect = { + let mut rect = ui.max_rect(); + rect.max.y = rect.min.y + Content::WINDOW_TITLE_HEIGHT; + rect + }; + + let title_bg_rect = { + let mut r = title_rect.clone(); + r.max.y += TitlePanel::HEIGHT - 1.0; + r + }; + let is_mac = egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac; + let window_title_bg = RectShape::new( + title_bg_rect, + if is_fullscreen || is_mac { + CornerRadius::ZERO + } else { + CornerRadius { + nw: 8.0 as u8, + ne: 8.0 as u8, + sw: 0.0 as u8, + se: 0.0 as u8, + } + }, + Colors::yellow_dark(), + Stroke::new(1.0, Colors::STROKE), + StrokeKind::Outside, + ); + // Draw title background. + ui.painter().add(window_title_bg); + + let painter = ui.painter(); + + let interact_rect = { + let mut rect = title_rect.clone(); + rect.max.x -= 128.0; + rect.min.x += 85.0; + if !is_fullscreen { + rect.min.y += Content::WINDOW_FRAME_MARGIN; + } + rect + }; + let title_resp = ui.interact( + interact_rect, + egui::Id::new("window_title"), + egui::Sense::drag(), + ); + // Interact with the window title (drag to move window): + if !is_fullscreen && title_resp.drag_started_by(egui::PointerButton::Primary) { + ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); + } + + // Paint the title. + let title_text = format!("Goblin ツ · Build {}", crate::BUILD); + painter.text( + title_rect.center(), + egui::Align2::CENTER_CENTER, + title_text, + egui::FontId::proportional(15.0), + Colors::title(true), + ); + + ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // Draw button to close window. + View::title_button_small(ui, X, |_| { + if Modal::opened().is_none() || Modal::opened_closeable() { + Content::show_exit_modal(); + } + }); + + // Draw fullscreen button. + let fullscreen_icon = if is_fullscreen { ARROWS_IN } else { ARROWS_OUT }; + View::title_button_small(ui, fullscreen_icon, |ui| { + ui.ctx() + .send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen)); + }); + + // Draw button to minimize window. + View::title_button_small(ui, CARET_DOWN, |ui| { + ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)); + }); + + // Draw application icon. + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + // Draw button to minimize window. + let use_dark = AppConfig::dark_theme().unwrap_or(false); + let theme_icon = if use_dark { SUN } else { MOON }; + View::title_button_small(ui, theme_icon, |ui| { + AppConfig::set_dark_theme(!use_dark); + crate::setup_visuals(ui.ctx()); + }); + }, + ); + }); + }); + } + + /// Setup window resize area. + fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) { + let mut rect = ui.max_rect(); + + // Setup area id, cursor and area rect based on direction. + let (id, cursor, rect) = match direction { + ResizeDirection::North => ("n", CursorIcon::ResizeNorth, { + rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0; + rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN; + rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0; + rect + }), + ResizeDirection::East => ("e", CursorIcon::ResizeEast, { + rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0; + rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN; + rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0; + rect + }), + ResizeDirection::South => ("s", CursorIcon::ResizeSouth, { + rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0; + rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN; + rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0; + rect + }), + ResizeDirection::West => ("w", CursorIcon::ResizeWest, { + rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0; + rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN; + rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0; + rect + }), + ResizeDirection::NorthWest => ("nw", CursorIcon::ResizeNorthWest, { + rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN * 2.0; + rect.max.x = rect.max.y + Content::WINDOW_FRAME_MARGIN * 2.0; + rect + }), + ResizeDirection::NorthEast => ("ne", CursorIcon::ResizeNorthEast, { + rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0; + rect.max.y = Content::WINDOW_FRAME_MARGIN * 2.0; + rect + }), + ResizeDirection::SouthEast => ("se", CursorIcon::ResizeSouthEast, { + rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0; + rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0; + rect + }), + ResizeDirection::SouthWest => ("sw", CursorIcon::ResizeSouthWest, { + rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0; + rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN * 2.0; + rect + }), + }; + + // Setup resize area. + let id = egui::Id::new("window_resize").with(id); + let sense = egui::Sense::drag(); + let area_resp = ui.interact(rect, id, sense).on_hover_cursor(cursor); + if area_resp.dragged() { + if self.resize_direction.is_none() { + self.resize_direction = Some(direction.clone()); + ui.ctx() + .send_viewport_cmd(ViewportCommand::BeginResize(direction)); + } + } + if area_resp.drag_stopped() { + self.resize_direction = None; + } + } +} + +/// To draw with egui`s eframe (for wgpu, glow backends and wasm target). +impl eframe::App for App { + fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) { + ctx.plugin_or_default::(); + self.ui(ctx); + } + + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + let os = egui::os::OperatingSystem::from_target_os(); + let is_win = os == egui::os::OperatingSystem::Windows; + let is_mac = os == egui::os::OperatingSystem::Mac; + if !View::is_desktop() || is_win || is_mac { + return Colors::fill_lite().to_normalized_gamma_f32(); + } + Colors::TRANSPARENT.to_normalized_gamma_f32() + } +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Handle Back key code event from Android. +pub extern "C" fn Java_mw_gri_android_MainActivity_onBack( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) { + BACK_BUTTON_PRESSED.store(true, Ordering::Relaxed); +} diff --git a/src/gui/colors.rs b/src/gui/colors.rs new file mode 100644 index 00000000..15bccb7e --- /dev/null +++ b/src/gui/colors.rs @@ -0,0 +1,167 @@ +// Copyright 2023 The Grim Developers +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Legacy color API mapped onto the Goblin design tokens in [`crate::gui::theme`]. +//! Existing call sites keep compiling; everything sources from the active theme. + +use egui::Color32; + +use crate::gui::theme; + +/// Provides color values based on the current theme tokens. +pub struct Colors; + +const SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(100); +const DARK_SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(170); + +const INK: Color32 = Color32::from_rgb(0x0E, 0x0E, 0x0C); +const PAPER: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xF7); + +fn dark_base() -> bool { + theme::tokens().dark_base +} + +impl Colors { + pub const FILL_DEEP: Color32 = Color32::from_rgb(0xF2, 0xF1, 0xEC); + pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0); + pub const STROKE: Color32 = Color32::from_rgba_premultiplied(1, 1, 1, 20); + + /// Ink when `true`, paper when `false` (theme aware: maps to text/bg). + pub fn white_or_black(black_in_white: bool) -> Color32 { + let t = theme::tokens(); + if black_in_white { t.text } else { t.bg } + } + + pub fn semi_transparent() -> Color32 { + if dark_base() { + DARK_SEMI_TRANSPARENT + } else { + SEMI_TRANSPARENT + } + } + + pub fn gold() -> Color32 { + theme::tokens().accent + } + + pub fn gold_dark() -> Color32 { + theme::tokens().accent_dark + } + + pub fn yellow() -> Color32 { + theme::tokens().accent + } + + pub fn yellow_dark() -> Color32 { + theme::tokens().accent_dark + } + + /// Ink color to draw on top of accent fills. + pub fn accent_ink() -> Color32 { + theme::tokens().accent_ink + } + + pub fn green() -> Color32 { + theme::tokens().pos + } + + pub fn red() -> Color32 { + theme::tokens().neg + } + + pub fn blue() -> Color32 { + if dark_base() { + Color32::from_rgb(0x7B, 0xA7, 0xFF) + } else { + Color32::from_rgb(0x0E, 0x62, 0xD0) + } + } + + pub fn fill() -> Color32 { + theme::tokens().bg + } + + pub fn fill_deep() -> Color32 { + theme::tokens().surface2 + } + + pub fn fill_lite() -> Color32 { + theme::tokens().surface + } + + pub fn checkbox() -> Color32 { + theme::tokens().text_dim + } + + pub fn text(always_light: bool) -> Color32 { + if always_light { + // Forced light-theme ink, used over always-light surfaces like QR cards. + Color32::from_rgb(0x6B, 0x6A, 0x63) + } else { + theme::tokens().text_dim + } + } + + pub fn text_button() -> Color32 { + theme::tokens().text + } + + pub fn title(always_light: bool) -> Color32 { + if always_light { + INK + } else { + theme::tokens().text + } + } + + pub fn gray() -> Color32 { + theme::tokens().text_mute + } + + pub fn stroke() -> Color32 { + theme::tokens().line + } + + pub fn inactive_text() -> Color32 { + theme::tokens().text_mute + } + + pub fn item_button_text() -> Color32 { + theme::tokens().text_dim + } + + pub fn item_stroke() -> Color32 { + theme::tokens().line + } + + pub fn item_hover() -> Color32 { + theme::tokens().hover + } + + /// Positive amount color. + pub fn pos() -> Color32 { + theme::tokens().pos + } + + /// Always-dark ink (brand black). + pub const fn ink() -> Color32 { + INK + } + + /// Always-light paper (brand white). + pub const fn paper() -> Color32 { + PAPER + } +} diff --git a/src/gui/icons.rs b/src/gui/icons.rs new file mode 100644 index 00000000..0714d5df --- /dev/null +++ b/src/gui/icons.rs @@ -0,0 +1,1273 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +#![allow(unused)] +pub const ADDRESS_BOOK: &'static str = "\u{E900}"; +pub const AIRPLANE: &'static str = "\u{E901}"; +pub const AIRPLANE_IN_FLIGHT: &'static str = "\u{E902}"; +pub const AIRPLANE_LANDING: &'static str = "\u{E903}"; +pub const AIRPLANE_TAKEOFF: &'static str = "\u{E904}"; +pub const AIRPLANE_TILT: &'static str = "\u{E905}"; +pub const AIRPLAY: &'static str = "\u{E906}"; +pub const AIR_TRAFFIC_CONTROL: &'static str = "\u{E907}"; +pub const ALARM: &'static str = "\u{E908}"; +pub const ALIEN: &'static str = "\u{E909}"; +pub const ALIGN_BOTTOM: &'static str = "\u{E90A}"; +pub const ALIGN_BOTTOM_SIMPLE: &'static str = "\u{E90B}"; +pub const ALIGN_CENTER_HORIZONTAL: &'static str = "\u{E90C}"; +pub const ALIGN_CENTER_HORIZONTAL_SIMPLE: &'static str = "\u{E90D}"; +pub const ALIGN_CENTER_VERTICAL: &'static str = "\u{E90E}"; +pub const ALIGN_CENTER_VERTICAL_SIMPLE: &'static str = "\u{E90F}"; +pub const ALIGN_LEFT: &'static str = "\u{E910}"; +pub const ALIGN_LEFT_SIMPLE: &'static str = "\u{E911}"; +pub const ALIGN_RIGHT: &'static str = "\u{E912}"; +pub const ALIGN_RIGHT_SIMPLE: &'static str = "\u{E913}"; +pub const ALIGN_TOP: &'static str = "\u{E914}"; +pub const ALIGN_TOP_SIMPLE: &'static str = "\u{E915}"; +pub const AMAZON_LOGO: &'static str = "\u{E916}"; +pub const ANCHOR: &'static str = "\u{E917}"; +pub const ANCHOR_SIMPLE: &'static str = "\u{E918}"; +pub const ANDROID_LOGO: &'static str = "\u{E919}"; +pub const ANGULAR_LOGO: &'static str = "\u{E91A}"; +pub const APERTURE: &'static str = "\u{E91B}"; +pub const APPLE_LOGO: &'static str = "\u{E91C}"; +pub const APPLE_PODCASTS_LOGO: &'static str = "\u{E91D}"; +pub const APP_STORE_LOGO: &'static str = "\u{E91E}"; +pub const APP_WINDOW: &'static str = "\u{E91F}"; +pub const ARCHIVE: &'static str = "\u{E920}"; +pub const ARCHIVE_BOX: &'static str = "\u{E921}"; +pub const ARCHIVE_TRAY: &'static str = "\u{E922}"; +pub const ARMCHAIR: &'static str = "\u{E923}"; +pub const ARROW_ARC_LEFT: &'static str = "\u{E924}"; +pub const ARROW_ARC_RIGHT: &'static str = "\u{E925}"; +pub const ARROW_BEND_DOUBLE_UP_LEFT: &'static str = "\u{E926}"; +pub const ARROW_BEND_DOUBLE_UP_RIGHT: &'static str = "\u{E927}"; +pub const ARROW_BEND_DOWN_LEFT: &'static str = "\u{E928}"; +pub const ARROW_BEND_DOWN_RIGHT: &'static str = "\u{E929}"; +pub const ARROW_BEND_LEFT_DOWN: &'static str = "\u{E92A}"; +pub const ARROW_BEND_LEFT_UP: &'static str = "\u{E92B}"; +pub const ARROW_BEND_RIGHT_DOWN: &'static str = "\u{E92C}"; +pub const ARROW_BEND_RIGHT_UP: &'static str = "\u{E92D}"; +pub const ARROW_BEND_UP_LEFT: &'static str = "\u{E92E}"; +pub const ARROW_BEND_UP_RIGHT: &'static str = "\u{E92F}"; +pub const ARROW_CIRCLE_DOWN: &'static str = "\u{E930}"; +pub const ARROW_CIRCLE_DOWN_LEFT: &'static str = "\u{E931}"; +pub const ARROW_CIRCLE_DOWN_RIGHT: &'static str = "\u{E932}"; +pub const ARROW_CIRCLE_LEFT: &'static str = "\u{E933}"; +pub const ARROW_CIRCLE_RIGHT: &'static str = "\u{E934}"; +pub const ARROW_CIRCLE_UP: &'static str = "\u{E935}"; +pub const ARROW_CIRCLE_UP_LEFT: &'static str = "\u{E936}"; +pub const ARROW_CIRCLE_UP_RIGHT: &'static str = "\u{E937}"; +pub const ARROW_CLOCKWISE: &'static str = "\u{E938}"; +pub const ARROW_COUNTER_CLOCKWISE: &'static str = "\u{E939}"; +pub const ARROW_DOWN: &'static str = "\u{E93A}"; +pub const ARROW_DOWN_LEFT: &'static str = "\u{E93B}"; +pub const ARROW_DOWN_RIGHT: &'static str = "\u{E93C}"; +pub const ARROW_ELBOW_DOWN_LEFT: &'static str = "\u{E93D}"; +pub const ARROW_ELBOW_DOWN_RIGHT: &'static str = "\u{E93E}"; +pub const ARROW_ELBOW_LEFT: &'static str = "\u{E93F}"; +pub const ARROW_ELBOW_LEFT_DOWN: &'static str = "\u{E940}"; +pub const ARROW_ELBOW_LEFT_UP: &'static str = "\u{E941}"; +pub const ARROW_ELBOW_RIGHT: &'static str = "\u{E942}"; +pub const ARROW_ELBOW_RIGHT_DOWN: &'static str = "\u{E943}"; +pub const ARROW_ELBOW_RIGHT_UP: &'static str = "\u{E944}"; +pub const ARROW_ELBOW_UP_LEFT: &'static str = "\u{E945}"; +pub const ARROW_ELBOW_UP_RIGHT: &'static str = "\u{E946}"; +pub const ARROW_FAT_DOWN: &'static str = "\u{E947}"; +pub const ARROW_FAT_LEFT: &'static str = "\u{E948}"; +pub const ARROW_FAT_LINE_DOWN: &'static str = "\u{E949}"; +pub const ARROW_FAT_LINE_LEFT: &'static str = "\u{E94A}"; +pub const ARROW_FAT_LINE_RIGHT: &'static str = "\u{E94B}"; +pub const ARROW_FAT_LINES_DOWN: &'static str = "\u{E94C}"; +pub const ARROW_FAT_LINES_LEFT: &'static str = "\u{E94D}"; +pub const ARROW_FAT_LINES_RIGHT: &'static str = "\u{E94E}"; +pub const ARROW_FAT_LINES_UP: &'static str = "\u{E94F}"; +pub const ARROW_FAT_LINE_UP: &'static str = "\u{E950}"; +pub const ARROW_FAT_RIGHT: &'static str = "\u{E951}"; +pub const ARROW_FAT_UP: &'static str = "\u{E952}"; +pub const ARROW_LEFT: &'static str = "\u{E953}"; +pub const ARROW_LINE_DOWN: &'static str = "\u{E954}"; +pub const ARROW_LINE_DOWN_LEFT: &'static str = "\u{E955}"; +pub const ARROW_LINE_DOWN_RIGHT: &'static str = "\u{E956}"; +pub const ARROW_LINE_LEFT: &'static str = "\u{E957}"; +pub const ARROW_LINE_RIGHT: &'static str = "\u{E958}"; +pub const ARROW_LINE_UP: &'static str = "\u{E959}"; +pub const ARROW_LINE_UP_LEFT: &'static str = "\u{E95A}"; +pub const ARROW_LINE_UP_RIGHT: &'static str = "\u{E95B}"; +pub const ARROW_RIGHT: &'static str = "\u{E95C}"; +pub const ARROWS_CLOCKWISE: &'static str = "\u{E95D}"; +pub const ARROWS_COUNTER_CLOCKWISE: &'static str = "\u{E95E}"; +pub const ARROWS_DOWN_UP: &'static str = "\u{E95F}"; +pub const ARROWS_HORIZONTAL: &'static str = "\u{E960}"; +pub const ARROWS_IN: &'static str = "\u{E961}"; +pub const ARROWS_IN_CARDINAL: &'static str = "\u{E962}"; +pub const ARROWS_IN_LINE_HORIZONTAL: &'static str = "\u{E963}"; +pub const ARROWS_IN_LINE_VERTICAL: &'static str = "\u{E964}"; +pub const ARROWS_IN_SIMPLE: &'static str = "\u{E965}"; +pub const ARROWS_LEFT_RIGHT: &'static str = "\u{E966}"; +pub const ARROWS_MERGE: &'static str = "\u{E967}"; +pub const ARROWS_OUT: &'static str = "\u{E968}"; +pub const ARROWS_OUT_CARDINAL: &'static str = "\u{E969}"; +pub const ARROWS_OUT_LINE_HORIZONTAL: &'static str = "\u{E96A}"; +pub const ARROWS_OUT_LINE_VERTICAL: &'static str = "\u{E96B}"; +pub const ARROWS_OUT_SIMPLE: &'static str = "\u{E96C}"; +pub const ARROW_SQUARE_DOWN: &'static str = "\u{E96D}"; +pub const ARROW_SQUARE_DOWN_LEFT: &'static str = "\u{E96E}"; +pub const ARROW_SQUARE_DOWN_RIGHT: &'static str = "\u{E96F}"; +pub const ARROW_SQUARE_IN: &'static str = "\u{E970}"; +pub const ARROW_SQUARE_LEFT: &'static str = "\u{E971}"; +pub const ARROW_SQUARE_OUT: &'static str = "\u{E972}"; +pub const ARROW_SQUARE_RIGHT: &'static str = "\u{E973}"; +pub const ARROW_SQUARE_UP: &'static str = "\u{E974}"; +pub const ARROW_SQUARE_UP_LEFT: &'static str = "\u{E975}"; +pub const ARROW_SQUARE_UP_RIGHT: &'static str = "\u{E976}"; +pub const ARROWS_SPLIT: &'static str = "\u{E977}"; +pub const ARROWS_VERTICAL: &'static str = "\u{E978}"; +pub const ARROW_U_DOWN_LEFT: &'static str = "\u{E979}"; +pub const ARROW_U_DOWN_RIGHT: &'static str = "\u{E97A}"; +pub const ARROW_U_LEFT_DOWN: &'static str = "\u{E97B}"; +pub const ARROW_U_LEFT_UP: &'static str = "\u{E97C}"; +pub const ARROW_UP: &'static str = "\u{E97D}"; +pub const ARROW_UP_LEFT: &'static str = "\u{E97E}"; +pub const ARROW_UP_RIGHT: &'static str = "\u{E97F}"; +pub const ARROW_U_RIGHT_DOWN: &'static str = "\u{E980}"; +pub const ARROW_U_RIGHT_UP: &'static str = "\u{E981}"; +pub const ARROW_U_UP_LEFT: &'static str = "\u{E982}"; +pub const ARROW_U_UP_RIGHT: &'static str = "\u{E983}"; +pub const ARTICLE: &'static str = "\u{E984}"; +pub const ARTICLE_MEDIUM: &'static str = "\u{E985}"; +pub const ARTICLE_NY_TIMES: &'static str = "\u{E986}"; +pub const ASTERISK: &'static str = "\u{E987}"; +pub const ASTERISK_SIMPLE: &'static str = "\u{E988}"; +pub const AT: &'static str = "\u{E989}"; +pub const ATOM: &'static str = "\u{E98A}"; +pub const BABY: &'static str = "\u{E98B}"; +pub const BACKPACK: &'static str = "\u{E98C}"; +pub const BACKSPACE: &'static str = "\u{E98D}"; +pub const BAG: &'static str = "\u{E98E}"; +pub const BAG_SIMPLE: &'static str = "\u{E98F}"; +pub const BALLOON: &'static str = "\u{E990}"; +pub const BANDAIDS: &'static str = "\u{E991}"; +pub const BANK: &'static str = "\u{E992}"; +pub const BARBELL: &'static str = "\u{E993}"; +pub const BARCODE: &'static str = "\u{E994}"; +pub const BARRICADE: &'static str = "\u{E995}"; +pub const BASEBALL: &'static str = "\u{E996}"; +pub const BASEBALL_CAP: &'static str = "\u{E997}"; +pub const BASKET: &'static str = "\u{E998}"; +pub const BASKETBALL: &'static str = "\u{E999}"; +pub const BATHTUB: &'static str = "\u{E99A}"; +pub const BATTERY_CHARGING: &'static str = "\u{E99B}"; +pub const BATTERY_CHARGING_VERTICAL: &'static str = "\u{E99C}"; +pub const BATTERY_EMPTY: &'static str = "\u{E99D}"; +pub const BATTERY_FULL: &'static str = "\u{E99E}"; +pub const BATTERY_HIGH: &'static str = "\u{E99F}"; +pub const BATTERY_LOW: &'static str = "\u{E9A0}"; +pub const BATTERY_MEDIUM: &'static str = "\u{E9A1}"; +pub const BATTERY_PLUS: &'static str = "\u{E9A2}"; +pub const BATTERY_PLUS_VERTICAL: &'static str = "\u{E9A3}"; +pub const BATTERY_VERTICAL_EMPTY: &'static str = "\u{E9A4}"; +pub const BATTERY_VERTICAL_FULL: &'static str = "\u{E9A5}"; +pub const BATTERY_VERTICAL_HIGH: &'static str = "\u{E9A6}"; +pub const BATTERY_VERTICAL_LOW: &'static str = "\u{E9A7}"; +pub const BATTERY_VERTICAL_MEDIUM: &'static str = "\u{E9A8}"; +pub const BATTERY_WARNING: &'static str = "\u{E9A9}"; +pub const BATTERY_WARNING_VERTICAL: &'static str = "\u{E9AA}"; +pub const BED: &'static str = "\u{E9AB}"; +pub const BEER_BOTTLE: &'static str = "\u{E9AC}"; +pub const BEER_STEIN: &'static str = "\u{E9AD}"; +pub const BEHANCE_LOGO: &'static str = "\u{E9AE}"; +pub const BELL: &'static str = "\u{E9AF}"; +pub const BELL_RINGING: &'static str = "\u{E9B0}"; +pub const BELL_SIMPLE: &'static str = "\u{E9B1}"; +pub const BELL_SIMPLE_RINGING: &'static str = "\u{E9B2}"; +pub const BELL_SIMPLE_SLASH: &'static str = "\u{E9B3}"; +pub const BELL_SIMPLE_Z: &'static str = "\u{E9B4}"; +pub const BELL_SLASH: &'static str = "\u{E9B5}"; +pub const BELL_Z: &'static str = "\u{E9B6}"; +pub const BEZIER_CURVE: &'static str = "\u{E9B7}"; +pub const BICYCLE: &'static str = "\u{E9B8}"; +pub const BINOCULARS: &'static str = "\u{E9B9}"; +pub const BIRD: &'static str = "\u{E9BA}"; +pub const BLUETOOTH: &'static str = "\u{E9BB}"; +pub const BLUETOOTH_CONNECTED: &'static str = "\u{E9BC}"; +pub const BLUETOOTH_SLASH: &'static str = "\u{E9BD}"; +pub const BLUETOOTH_X: &'static str = "\u{E9BE}"; +pub const BOAT: &'static str = "\u{E9BF}"; +pub const BONE: &'static str = "\u{E9C0}"; +pub const BOOK: &'static str = "\u{E9C1}"; +pub const BOOK_BOOKMARK: &'static str = "\u{E9C2}"; +pub const BOOKMARK: &'static str = "\u{E9C3}"; +pub const BOOKMARKS: &'static str = "\u{E9C4}"; +pub const BOOKMARK_SIMPLE: &'static str = "\u{E9C5}"; +pub const BOOKMARKS_SIMPLE: &'static str = "\u{E9C6}"; +pub const BOOK_OPEN: &'static str = "\u{E9C7}"; +pub const BOOK_OPEN_TEXT: &'static str = "\u{E9C8}"; +pub const BOOKS: &'static str = "\u{E9C9}"; +pub const BOOT: &'static str = "\u{E9CA}"; +pub const BOUNDING_BOX: &'static str = "\u{E9CB}"; +pub const BOWL_FOOD: &'static str = "\u{E9CC}"; +pub const BRACKETS_ANGLE: &'static str = "\u{E9CD}"; +pub const BRACKETS_CURLY: &'static str = "\u{E9CE}"; +pub const BRACKETS_ROUND: &'static str = "\u{E9CF}"; +pub const BRACKETS_SQUARE: &'static str = "\u{E9D0}"; +pub const BRAIN: &'static str = "\u{E9D1}"; +pub const BRANDY: &'static str = "\u{E9D2}"; +pub const BRIDGE: &'static str = "\u{E9D3}"; +pub const BRIEFCASE: &'static str = "\u{E9D4}"; +pub const BRIEFCASE_METAL: &'static str = "\u{E9D5}"; +pub const BROADCAST: &'static str = "\u{E9D6}"; +pub const BROOM: &'static str = "\u{E9D7}"; +pub const BROWSER: &'static str = "\u{E9D8}"; +pub const BROWSERS: &'static str = "\u{E9D9}"; +pub const BUG: &'static str = "\u{E9DA}"; +pub const BUG_BEETLE: &'static str = "\u{E9DB}"; +pub const BUG_DROID: &'static str = "\u{E9DC}"; +pub const BUILDINGS: &'static str = "\u{E9DD}"; +pub const BUS: &'static str = "\u{E9DE}"; +pub const BUTTERFLY: &'static str = "\u{E9DF}"; +pub const CACTUS: &'static str = "\u{E9E0}"; +pub const CAKE: &'static str = "\u{E9E1}"; +pub const CALCULATOR: &'static str = "\u{E9E2}"; +pub const CALENDAR: &'static str = "\u{E9E3}"; +pub const CALENDAR_BLANK: &'static str = "\u{E9E4}"; +pub const CALENDAR_CHECK: &'static str = "\u{E9E5}"; +pub const CALENDAR_PLUS: &'static str = "\u{E9E6}"; +pub const CALENDAR_X: &'static str = "\u{E9E7}"; +pub const CALL_BELL: &'static str = "\u{E9E8}"; +pub const CAMERA: &'static str = "\u{E9E9}"; +pub const CAMERA_PLUS: &'static str = "\u{E9EA}"; +pub const CAMERA_ROTATE: &'static str = "\u{E9EB}"; +pub const CAMERA_SLASH: &'static str = "\u{E9EC}"; +pub const CAMPFIRE: &'static str = "\u{E9ED}"; +pub const CAR: &'static str = "\u{E9EE}"; +pub const CARDHOLDER: &'static str = "\u{E9EF}"; +pub const CARDS: &'static str = "\u{E9F0}"; +pub const CARET_CIRCLE_DOUBLE_DOWN: &'static str = "\u{E9F1}"; +pub const CARET_CIRCLE_DOUBLE_LEFT: &'static str = "\u{E9F2}"; +pub const CARET_CIRCLE_DOUBLE_RIGHT: &'static str = "\u{E9F3}"; +pub const CARET_CIRCLE_DOUBLE_UP: &'static str = "\u{E9F4}"; +pub const CARET_CIRCLE_DOWN: &'static str = "\u{E9F5}"; +pub const CARET_CIRCLE_LEFT: &'static str = "\u{E9F6}"; +pub const CARET_CIRCLE_RIGHT: &'static str = "\u{E9F7}"; +pub const CARET_CIRCLE_UP: &'static str = "\u{E9F8}"; +pub const CARET_CIRCLE_UP_DOWN: &'static str = "\u{E9F9}"; +pub const CARET_DOUBLE_DOWN: &'static str = "\u{E9FA}"; +pub const CARET_DOUBLE_LEFT: &'static str = "\u{E9FB}"; +pub const CARET_DOUBLE_RIGHT: &'static str = "\u{E9FC}"; +pub const CARET_DOUBLE_UP: &'static str = "\u{E9FD}"; +pub const CARET_DOWN: &'static str = "\u{E9FE}"; +pub const CARET_LEFT: &'static str = "\u{E9FF}"; +pub const CARET_RIGHT: &'static str = "\u{EA00}"; +pub const CARET_UP: &'static str = "\u{EA01}"; +pub const CARET_UP_DOWN: &'static str = "\u{EA02}"; +pub const CAR_PROFILE: &'static str = "\u{EA03}"; +pub const CARROT: &'static str = "\u{EA04}"; +pub const CAR_SIMPLE: &'static str = "\u{EA05}"; +pub const CASSETTE_TAPE: &'static str = "\u{EA06}"; +pub const CASTLE_TURRET: &'static str = "\u{EA07}"; +pub const CAT: &'static str = "\u{EA08}"; +pub const CELL_SIGNAL_FULL: &'static str = "\u{EA09}"; +pub const CELL_SIGNAL_HIGH: &'static str = "\u{EA0A}"; +pub const CELL_SIGNAL_LOW: &'static str = "\u{EA0B}"; +pub const CELL_SIGNAL_MEDIUM: &'static str = "\u{EA0C}"; +pub const CELL_SIGNAL_NONE: &'static str = "\u{EA0D}"; +pub const CELL_SIGNAL_SLASH: &'static str = "\u{EA0E}"; +pub const CELL_SIGNAL_X: &'static str = "\u{EA0F}"; +pub const CERTIFICATE: &'static str = "\u{EA10}"; +pub const CHAIR: &'static str = "\u{EA11}"; +pub const CHALKBOARD: &'static str = "\u{EA12}"; +pub const CHALKBOARD_SIMPLE: &'static str = "\u{EA13}"; +pub const CHALKBOARD_TEACHER: &'static str = "\u{EA14}"; +pub const CHAMPAGNE: &'static str = "\u{EA15}"; +pub const CHARGING_STATION: &'static str = "\u{EA16}"; +pub const CHART_BAR: &'static str = "\u{EA17}"; +pub const CHART_BAR_HORIZONTAL: &'static str = "\u{EA18}"; +pub const CHART_DONUT: &'static str = "\u{EA19}"; +pub const CHART_LINE: &'static str = "\u{EA1A}"; +pub const CHART_LINE_DOWN: &'static str = "\u{EA1B}"; +pub const CHART_LINE_UP: &'static str = "\u{EA1C}"; +pub const CHART_PIE: &'static str = "\u{EA1D}"; +pub const CHART_PIE_SLICE: &'static str = "\u{EA1E}"; +pub const CHART_POLAR: &'static str = "\u{EA1F}"; +pub const CHART_SCATTER: &'static str = "\u{EA20}"; +pub const CHAT: &'static str = "\u{EA21}"; +pub const CHAT_CENTERED: &'static str = "\u{EA22}"; +pub const CHAT_CENTERED_DOTS: &'static str = "\u{EA23}"; +pub const CHAT_CENTERED_TEXT: &'static str = "\u{EA24}"; +pub const CHAT_CIRCLE: &'static str = "\u{EA25}"; +pub const CHAT_CIRCLE_DOTS: &'static str = "\u{EA26}"; +pub const CHAT_CIRCLE_TEXT: &'static str = "\u{EA27}"; +pub const CHAT_DOTS: &'static str = "\u{EA28}"; +pub const CHATS: &'static str = "\u{EA29}"; +pub const CHATS_CIRCLE: &'static str = "\u{EA2A}"; +pub const CHATS_TEARDROP: &'static str = "\u{EA2B}"; +pub const CHAT_TEARDROP: &'static str = "\u{EA2C}"; +pub const CHAT_TEARDROP_DOTS: &'static str = "\u{EA2D}"; +pub const CHAT_TEARDROP_TEXT: &'static str = "\u{EA2E}"; +pub const CHAT_TEXT: &'static str = "\u{EA2F}"; +pub const CHECK: &'static str = "\u{EA30}"; +pub const CHECK_CIRCLE: &'static str = "\u{EA31}"; +pub const CHECK_FAT: &'static str = "\u{EA32}"; +pub const CHECKS: &'static str = "\u{EA33}"; +pub const CHECK_SQUARE: &'static str = "\u{EA34}"; +pub const CHECK_SQUARE_OFFSET: &'static str = "\u{EA35}"; +pub const CHURCH: &'static str = "\u{EA36}"; +pub const CIRCLE: &'static str = "\u{EA37}"; +pub const CIRCLE_DASHED: &'static str = "\u{EA38}"; +pub const CIRCLE_HALF: &'static str = "\u{EA39}"; +pub const CIRCLE_HALF_TILT: &'static str = "\u{EA3A}"; +pub const CIRCLE_NOTCH: &'static str = "\u{EA3B}"; +pub const CIRCLES_FOUR: &'static str = "\u{EA3C}"; +pub const CIRCLES_THREE: &'static str = "\u{EA3D}"; +pub const CIRCLES_THREE_PLUS: &'static str = "\u{EA3E}"; +pub const CIRCUITRY: &'static str = "\u{EA3F}"; +pub const CLIPBOARD: &'static str = "\u{EA40}"; +pub const CLIPBOARD_TEXT: &'static str = "\u{EA41}"; +pub const CLOCK: &'static str = "\u{EA42}"; +pub const CLOCK_AFTERNOON: &'static str = "\u{EA43}"; +pub const CLOCK_CLOCKWISE: &'static str = "\u{EA44}"; +pub const CLOCK_COUNTDOWN: &'static str = "\u{EA45}"; +pub const CLOCK_COUNTER_CLOCKWISE: &'static str = "\u{EA46}"; +pub const CLOSED_CAPTIONING: &'static str = "\u{EA47}"; +pub const CLOUD: &'static str = "\u{EA48}"; +pub const CLOUD_ARROW_DOWN: &'static str = "\u{EA49}"; +pub const CLOUD_ARROW_UP: &'static str = "\u{EA4A}"; +pub const CLOUD_CHECK: &'static str = "\u{EA4B}"; +pub const CLOUD_FOG: &'static str = "\u{EA4C}"; +pub const CLOUD_LIGHTNING: &'static str = "\u{EA4D}"; +pub const CLOUD_MOON: &'static str = "\u{EA4E}"; +pub const CLOUD_RAIN: &'static str = "\u{EA4F}"; +pub const CLOUD_SLASH: &'static str = "\u{EA50}"; +pub const CLOUD_SNOW: &'static str = "\u{EA51}"; +pub const CLOUD_SUN: &'static str = "\u{EA52}"; +pub const CLOUD_WARNING: &'static str = "\u{EA53}"; +pub const CLOUD_X: &'static str = "\u{EA54}"; +pub const CLUB: &'static str = "\u{EA55}"; +pub const COAT_HANGER: &'static str = "\u{EA56}"; +pub const CODA_LOGO: &'static str = "\u{EA57}"; +pub const CODE: &'static str = "\u{EA58}"; +pub const CODE_BLOCK: &'static str = "\u{EA59}"; +pub const CODEPEN_LOGO: &'static str = "\u{EA5A}"; +pub const CODESANDBOX_LOGO: &'static str = "\u{EA5B}"; +pub const CODE_SIMPLE: &'static str = "\u{EA5C}"; +pub const COFFEE: &'static str = "\u{EA5D}"; +pub const COIN: &'static str = "\u{EA5E}"; +pub const COINS: &'static str = "\u{EA5F}"; +pub const COIN_VERTICAL: &'static str = "\u{EA60}"; +pub const COLUMNS: &'static str = "\u{EA61}"; +pub const COMMAND: &'static str = "\u{EA62}"; +pub const COMPASS: &'static str = "\u{EA63}"; +pub const COMPASS_TOOL: &'static str = "\u{EA64}"; +pub const COMPUTER_TOWER: &'static str = "\u{EA65}"; +pub const CONFETTI: &'static str = "\u{EA66}"; +pub const CONTACTLESS_PAYMENT: &'static str = "\u{EA67}"; +pub const CONTROL: &'static str = "\u{EA68}"; +pub const COOKIE: &'static str = "\u{EA69}"; +pub const COOKING_POT: &'static str = "\u{EA6A}"; +pub const COPY: &'static str = "\u{EA6B}"; +pub const COPYLEFT: &'static str = "\u{EA6C}"; +pub const COPYRIGHT: &'static str = "\u{EA6D}"; +pub const COPY_SIMPLE: &'static str = "\u{EA6E}"; +pub const CORNERS_IN: &'static str = "\u{EA6F}"; +pub const CORNERS_OUT: &'static str = "\u{EA70}"; +pub const COUCH: &'static str = "\u{EA71}"; +pub const CPU: &'static str = "\u{EA72}"; +pub const CREDIT_CARD: &'static str = "\u{EA73}"; +pub const CROP: &'static str = "\u{EA74}"; +pub const CROSS: &'static str = "\u{EA75}"; +pub const CROSSHAIR: &'static str = "\u{EA76}"; +pub const CROSSHAIR_SIMPLE: &'static str = "\u{EA77}"; +pub const CROWN: &'static str = "\u{EA78}"; +pub const CROWN_SIMPLE: &'static str = "\u{EA79}"; +pub const CUBE: &'static str = "\u{EA7A}"; +pub const CUBE_FOCUS: &'static str = "\u{EA7B}"; +pub const CUBE_TRANSPARENT: &'static str = "\u{EA7C}"; +pub const CURRENCY_BTC: &'static str = "\u{EA7D}"; +pub const CURRENCY_CIRCLE_DOLLAR: &'static str = "\u{EA7E}"; +pub const CURRENCY_CNY: &'static str = "\u{EA7F}"; +pub const CURRENCY_DOLLAR: &'static str = "\u{EA80}"; +pub const CURRENCY_DOLLAR_SIMPLE: &'static str = "\u{EA81}"; +pub const CURRENCY_ETH: &'static str = "\u{EA82}"; +pub const CURRENCY_EUR: &'static str = "\u{EA83}"; +pub const CURRENCY_GBP: &'static str = "\u{EA84}"; +pub const CURRENCY_INR: &'static str = "\u{EA85}"; +pub const CURRENCY_JPY: &'static str = "\u{EA86}"; +pub const CURRENCY_KRW: &'static str = "\u{EA87}"; +pub const CURRENCY_KZT: &'static str = "\u{EA88}"; +pub const CURRENCY_NGN: &'static str = "\u{EA89}"; +pub const CURRENCY_RUB: &'static str = "\u{EA8A}"; +pub const CURSOR: &'static str = "\u{EA8B}"; +pub const CURSOR_CLICK: &'static str = "\u{EA8C}"; +pub const CURSOR_TEXT: &'static str = "\u{EA8D}"; +pub const CYLINDER: &'static str = "\u{EA8E}"; +pub const DATABASE: &'static str = "\u{EA8F}"; +pub const DESKTOP: &'static str = "\u{EA90}"; +pub const DESKTOP_TOWER: &'static str = "\u{EA91}"; +pub const DETECTIVE: &'static str = "\u{EA92}"; +pub const DEVICE_MOBILE: &'static str = "\u{EA93}"; +pub const DEVICE_MOBILE_CAMERA: &'static str = "\u{EA94}"; +pub const DEVICE_MOBILE_SPEAKER: &'static str = "\u{EA95}"; +pub const DEVICES: &'static str = "\u{EA96}"; +pub const DEVICE_TABLET: &'static str = "\u{EA97}"; +pub const DEVICE_TABLET_CAMERA: &'static str = "\u{EA98}"; +pub const DEVICE_TABLET_SPEAKER: &'static str = "\u{EA99}"; +pub const DEV_TO_LOGO: &'static str = "\u{EA9A}"; +pub const DIAMOND: &'static str = "\u{EA9B}"; +pub const DIAMONDS_FOUR: &'static str = "\u{EA9C}"; +pub const DICE_FIVE: &'static str = "\u{EA9D}"; +pub const DICE_FOUR: &'static str = "\u{EA9E}"; +pub const DICE_ONE: &'static str = "\u{EA9F}"; +pub const DICE_SIX: &'static str = "\u{EAA0}"; +pub const DICE_THREE: &'static str = "\u{EAA1}"; +pub const DICE_TWO: &'static str = "\u{EAA2}"; +pub const DISC: &'static str = "\u{EAA3}"; +pub const DISCORD_LOGO: &'static str = "\u{EAA4}"; +pub const DIVIDE: &'static str = "\u{EAA5}"; +pub const DNA: &'static str = "\u{EAA6}"; +pub const DOG: &'static str = "\u{EAA7}"; +pub const DOOR: &'static str = "\u{EAA8}"; +pub const DOOR_OPEN: &'static str = "\u{EAA9}"; +pub const DOT: &'static str = "\u{EAAA}"; +pub const DOT_OUTLINE: &'static str = "\u{EAAB}"; +pub const DOTS_NINE: &'static str = "\u{EAAC}"; +pub const DOTS_SIX: &'static str = "\u{EAAD}"; +pub const DOTS_SIX_VERTICAL: &'static str = "\u{EAAE}"; +pub const DOTS_THREE: &'static str = "\u{EAAF}"; +pub const DOTS_THREE_CIRCLE: &'static str = "\u{EAB0}"; +pub const DOTS_THREE_CIRCLE_VERTICAL: &'static str = "\u{EAB1}"; +pub const DOTS_THREE_OUTLINE: &'static str = "\u{EAB2}"; +pub const DOTS_THREE_OUTLINE_VERTICAL: &'static str = "\u{EAB3}"; +pub const DOTS_THREE_VERTICAL: &'static str = "\u{EAB4}"; +pub const DOWNLOAD: &'static str = "\u{EAB5}"; +pub const DOWNLOAD_SIMPLE: &'static str = "\u{EAB6}"; +pub const DRESS: &'static str = "\u{EAB7}"; +pub const DRIBBBLE_LOGO: &'static str = "\u{EAB8}"; +pub const DROP: &'static str = "\u{EAB9}"; +pub const DROPBOX_LOGO: &'static str = "\u{EABA}"; +pub const DROP_HALF: &'static str = "\u{EABB}"; +pub const DROP_HALF_BOTTOM: &'static str = "\u{EABC}"; +pub const EAR: &'static str = "\u{EABD}"; +pub const EAR_SLASH: &'static str = "\u{EABE}"; +pub const EGG: &'static str = "\u{EABF}"; +pub const EGG_CRACK: &'static str = "\u{EAC0}"; +pub const EJECT: &'static str = "\u{EAC1}"; +pub const EJECT_SIMPLE: &'static str = "\u{EAC2}"; +pub const ELEVATOR: &'static str = "\u{EAC3}"; +pub const ENGINE: &'static str = "\u{EAC4}"; +pub const ENVELOPE: &'static str = "\u{EAC5}"; +pub const ENVELOPE_OPEN: &'static str = "\u{EAC6}"; +pub const ENVELOPE_SIMPLE: &'static str = "\u{EAC7}"; +pub const ENVELOPE_SIMPLE_OPEN: &'static str = "\u{EAC8}"; +pub const EQUALIZER: &'static str = "\u{EAC9}"; +pub const EQUALS: &'static str = "\u{EACA}"; +pub const ERASER: &'static str = "\u{EACB}"; +pub const ESCALATOR_DOWN: &'static str = "\u{EACC}"; +pub const ESCALATOR_UP: &'static str = "\u{EACD}"; +pub const EXAM: &'static str = "\u{EACE}"; +pub const EXCLUDE: &'static str = "\u{EACF}"; +pub const EXCLUDE_SQUARE: &'static str = "\u{EAD0}"; +pub const EXPORT: &'static str = "\u{EAD1}"; +pub const EYE: &'static str = "\u{EAD2}"; +pub const EYE_CLOSED: &'static str = "\u{EAD3}"; +pub const EYEDROPPER: &'static str = "\u{EAD4}"; +pub const EYEDROPPER_SAMPLE: &'static str = "\u{EAD5}"; +pub const EYEGLASSES: &'static str = "\u{EAD6}"; +pub const EYE_SLASH: &'static str = "\u{EAD7}"; +pub const FACEBOOK_LOGO: &'static str = "\u{EAD8}"; +pub const FACE_MASK: &'static str = "\u{EAD9}"; +pub const FACTORY: &'static str = "\u{EADA}"; +pub const FADERS: &'static str = "\u{EADB}"; +pub const FADERS_HORIZONTAL: &'static str = "\u{EADC}"; +pub const FAN: &'static str = "\u{EADD}"; +pub const FAST_FORWARD: &'static str = "\u{EADE}"; +pub const FAST_FORWARD_CIRCLE: &'static str = "\u{EADF}"; +pub const FEATHER: &'static str = "\u{EAE0}"; +pub const FIGMA_LOGO: &'static str = "\u{EAE1}"; +pub const FILE: &'static str = "\u{EAE2}"; +pub const FILE_ARCHIVE: &'static str = "\u{EAE3}"; +pub const FILE_ARROW_DOWN: &'static str = "\u{EAE4}"; +pub const FILE_ARROW_UP: &'static str = "\u{EAE5}"; +pub const FILE_AUDIO: &'static str = "\u{EAE6}"; +pub const FILE_CLOUD: &'static str = "\u{EAE7}"; +pub const FILE_CODE: &'static str = "\u{EAE8}"; +pub const FILE_CSS: &'static str = "\u{EAE9}"; +pub const FILE_CSV: &'static str = "\u{EAEA}"; +pub const FILE_DASHED: &'static str = "\u{EAEB}"; +pub const FILE_DOTTED: &'static str = "\u{EAEB}"; +pub const FILE_DOC: &'static str = "\u{EAEC}"; +pub const FILE_HTML: &'static str = "\u{EAED}"; +pub const FILE_IMAGE: &'static str = "\u{EAEE}"; +pub const FILE_JPG: &'static str = "\u{EAEF}"; +pub const FILE_JS: &'static str = "\u{EAF0}"; +pub const FILE_JSX: &'static str = "\u{EAF1}"; +pub const FILE_LOCK: &'static str = "\u{EAF2}"; +pub const FILE_MAGNIFYING_GLASS: &'static str = "\u{EAF3}"; +pub const FILE_SEARCH: &'static str = "\u{EAF3}"; +pub const FILE_MINUS: &'static str = "\u{EAF4}"; +pub const FILE_PDF: &'static str = "\u{EAF5}"; +pub const FILE_PLUS: &'static str = "\u{EAF6}"; +pub const FILE_PNG: &'static str = "\u{EAF7}"; +pub const FILE_PPT: &'static str = "\u{EAF8}"; +pub const FILE_RS: &'static str = "\u{EAF9}"; +pub const FILES: &'static str = "\u{EAFA}"; +pub const FILE_SQL: &'static str = "\u{EAFB}"; +pub const FILE_SVG: &'static str = "\u{EAFC}"; +pub const FILE_TEXT: &'static str = "\u{EAFD}"; +pub const FILE_TS: &'static str = "\u{EAFE}"; +pub const FILE_TSX: &'static str = "\u{EAFF}"; +pub const FILE_VIDEO: &'static str = "\u{EB00}"; +pub const FILE_VUE: &'static str = "\u{EB01}"; +pub const FILE_X: &'static str = "\u{EB02}"; +pub const FILE_XLS: &'static str = "\u{EB03}"; +pub const FILE_ZIP: &'static str = "\u{EB04}"; +pub const FILM_REEL: &'static str = "\u{EB05}"; +pub const FILM_SCRIPT: &'static str = "\u{EB06}"; +pub const FILM_SLATE: &'static str = "\u{EB07}"; +pub const FILM_STRIP: &'static str = "\u{EB08}"; +pub const FINGERPRINT: &'static str = "\u{EB09}"; +pub const FINGERPRINT_SIMPLE: &'static str = "\u{EB0A}"; +pub const FINN_THE_HUMAN: &'static str = "\u{EB0B}"; +pub const FIRE: &'static str = "\u{EB0C}"; +pub const FIRE_EXTINGUISHER: &'static str = "\u{EB0D}"; +pub const FIRE_SIMPLE: &'static str = "\u{EB0E}"; +pub const FIRST_AID: &'static str = "\u{EB0F}"; +pub const FIRST_AID_KIT: &'static str = "\u{EB10}"; +pub const FISH: &'static str = "\u{EB11}"; +pub const FISH_SIMPLE: &'static str = "\u{EB12}"; +pub const FLAG: &'static str = "\u{EB13}"; +pub const FLAG_BANNER: &'static str = "\u{EB14}"; +pub const FLAG_CHECKERED: &'static str = "\u{EB15}"; +pub const FLAG_PENNANT: &'static str = "\u{EB16}"; +pub const FLAME: &'static str = "\u{EB17}"; +pub const FLASHLIGHT: &'static str = "\u{EB18}"; +pub const FLASK: &'static str = "\u{EB19}"; +pub const FLOPPY_DISK: &'static str = "\u{EB1A}"; +pub const FLOPPY_DISK_BACK: &'static str = "\u{EB1B}"; +pub const FLOW_ARROW: &'static str = "\u{EB1C}"; +pub const FLOWER: &'static str = "\u{EB1D}"; +pub const FLOWER_LOTUS: &'static str = "\u{EB1E}"; +pub const FLOWER_TULIP: &'static str = "\u{EB1F}"; +pub const FLYING_SAUCER: &'static str = "\u{EB20}"; +pub const FOLDER: &'static str = "\u{EB21}"; +pub const FOLDER_DASHED: &'static str = "\u{EB22}"; +pub const FOLDER_DOTTED: &'static str = "\u{EB22}"; +pub const FOLDER_LOCK: &'static str = "\u{EB23}"; +pub const FOLDER_MINUS: &'static str = "\u{EB24}"; +pub const FOLDER_NOTCH: &'static str = "\u{EB25}"; +pub const FOLDER_NOTCH_MINUS: &'static str = "\u{EB26}"; +pub const FOLDER_NOTCH_OPEN: &'static str = "\u{EB27}"; +pub const FOLDER_NOTCH_PLUS: &'static str = "\u{EB28}"; +pub const FOLDER_OPEN: &'static str = "\u{EB29}"; +pub const FOLDER_PLUS: &'static str = "\u{EB2A}"; +pub const FOLDERS: &'static str = "\u{EB2B}"; +pub const FOLDER_SIMPLE: &'static str = "\u{EB2C}"; +pub const FOLDER_SIMPLE_DASHED: &'static str = "\u{EB2D}"; +pub const FOLDER_SIMPLE_DOTTED: &'static str = "\u{EB2D}"; +pub const FOLDER_SIMPLE_LOCK: &'static str = "\u{EB2E}"; +pub const FOLDER_SIMPLE_MINUS: &'static str = "\u{EB2F}"; +pub const FOLDER_SIMPLE_PLUS: &'static str = "\u{EB30}"; +pub const FOLDER_SIMPLE_STAR: &'static str = "\u{EB31}"; +pub const FOLDER_SIMPLE_USER: &'static str = "\u{EB32}"; +pub const FOLDER_STAR: &'static str = "\u{EB33}"; +pub const FOLDER_USER: &'static str = "\u{EB34}"; +pub const FOOTBALL: &'static str = "\u{EB35}"; +pub const FOOTPRINTS: &'static str = "\u{EB36}"; +pub const FORK_KNIFE: &'static str = "\u{EB37}"; +pub const FRAME_CORNERS: &'static str = "\u{EB38}"; +pub const FRAMER_LOGO: &'static str = "\u{EB39}"; +pub const FUNCTION: &'static str = "\u{EB3A}"; +pub const FUNNEL: &'static str = "\u{EB3B}"; +pub const FUNNEL_SIMPLE: &'static str = "\u{EB3C}"; +pub const GAME_CONTROLLER: &'static str = "\u{EB3D}"; +pub const GARAGE: &'static str = "\u{EB3E}"; +pub const GAS_CAN: &'static str = "\u{EB3F}"; +pub const GAS_PUMP: &'static str = "\u{EB40}"; +pub const GAUGE: &'static str = "\u{EB41}"; +pub const GAVEL: &'static str = "\u{EB42}"; +pub const GEAR: &'static str = "\u{EB43}"; +pub const GEAR_FINE: &'static str = "\u{EB44}"; +pub const GEAR_SIX: &'static str = "\u{EB45}"; +pub const GENDER_FEMALE: &'static str = "\u{EB46}"; +pub const GENDER_INTERSEX: &'static str = "\u{EB47}"; +pub const GENDER_MALE: &'static str = "\u{EB48}"; +pub const GENDER_NEUTER: &'static str = "\u{EB49}"; +pub const GENDER_NONBINARY: &'static str = "\u{EB4A}"; +pub const GENDER_TRANSGENDER: &'static str = "\u{EB4B}"; +pub const GHOST: &'static str = "\u{EB4C}"; +pub const GIF: &'static str = "\u{EB4D}"; +pub const GIFT: &'static str = "\u{EB4E}"; +pub const GIT_BRANCH: &'static str = "\u{EB4F}"; +pub const GIT_COMMIT: &'static str = "\u{EB50}"; +pub const GIT_DIFF: &'static str = "\u{EB51}"; +pub const GIT_FORK: &'static str = "\u{EB52}"; +pub const GITHUB_LOGO: &'static str = "\u{EB53}"; +pub const GITLAB_LOGO: &'static str = "\u{EB54}"; +pub const GITLAB_LOGO_SIMPLE: &'static str = "\u{EB55}"; +pub const GIT_MERGE: &'static str = "\u{EB56}"; +pub const GIT_PULL_REQUEST: &'static str = "\u{EB57}"; +pub const GLOBE: &'static str = "\u{EB58}"; +pub const GLOBE_HEMISPHERE_EAST: &'static str = "\u{EB59}"; +pub const GLOBE_HEMISPHERE_WEST: &'static str = "\u{EB5A}"; +pub const GLOBE_SIMPLE: &'static str = "\u{EB5B}"; +pub const GLOBE_STAND: &'static str = "\u{EB5C}"; +pub const GOGGLES: &'static str = "\u{EB5D}"; +pub const GOODREADS_LOGO: &'static str = "\u{EB5E}"; +pub const GOOGLE_CARDBOARD_LOGO: &'static str = "\u{EB5F}"; +pub const GOOGLE_CHROME_LOGO: &'static str = "\u{EB60}"; +pub const GOOGLE_DRIVE_LOGO: &'static str = "\u{EB61}"; +pub const GOOGLE_LOGO: &'static str = "\u{EB62}"; +pub const GOOGLE_PHOTOS_LOGO: &'static str = "\u{EB63}"; +pub const GOOGLE_PLAY_LOGO: &'static str = "\u{EB64}"; +pub const GOOGLE_PODCASTS_LOGO: &'static str = "\u{EB65}"; +pub const GRADIENT: &'static str = "\u{EB66}"; +pub const GRADUATION_CAP: &'static str = "\u{EB67}"; +pub const GRAINS: &'static str = "\u{EB68}"; +pub const GRAINS_SLASH: &'static str = "\u{EB69}"; +pub const GRAPH: &'static str = "\u{EB6A}"; +pub const GRID_FOUR: &'static str = "\u{EB6B}"; +pub const GRID_NINE: &'static str = "\u{EB6C}"; +pub const GUITAR: &'static str = "\u{EB6D}"; +pub const HAMBURGER: &'static str = "\u{EB6E}"; +pub const HAMMER: &'static str = "\u{EB6F}"; +pub const HAND: &'static str = "\u{EB70}"; +pub const HANDBAG: &'static str = "\u{EB71}"; +pub const HANDBAG_SIMPLE: &'static str = "\u{EB72}"; +pub const HAND_COINS: &'static str = "\u{EB73}"; +pub const HAND_EYE: &'static str = "\u{EB74}"; +pub const HAND_FIST: &'static str = "\u{EB75}"; +pub const HAND_GRABBING: &'static str = "\u{EB76}"; +pub const HAND_HEART: &'static str = "\u{EB77}"; +pub const HAND_PALM: &'static str = "\u{EB78}"; +pub const HAND_POINTING: &'static str = "\u{EB79}"; +pub const HANDS_CLAPPING: &'static str = "\u{EB7A}"; +pub const HANDSHAKE: &'static str = "\u{EB7B}"; +pub const HAND_SOAP: &'static str = "\u{EB7C}"; +pub const HANDS_PRAYING: &'static str = "\u{EB7D}"; +pub const HAND_SWIPE_LEFT: &'static str = "\u{EB7E}"; +pub const HAND_SWIPE_RIGHT: &'static str = "\u{EB7F}"; +pub const HAND_TAP: &'static str = "\u{EB80}"; +pub const HAND_WAVING: &'static str = "\u{EB81}"; +pub const HARD_DRIVE: &'static str = "\u{EB82}"; +pub const HARD_DRIVES: &'static str = "\u{EB83}"; +pub const HASH: &'static str = "\u{EB84}"; +pub const HASH_STRAIGHT: &'static str = "\u{EB85}"; +pub const HEADLIGHTS: &'static str = "\u{EB86}"; +pub const HEADPHONES: &'static str = "\u{EB87}"; +pub const HEADSET: &'static str = "\u{EB88}"; +pub const HEART: &'static str = "\u{EB89}"; +pub const HEARTBEAT: &'static str = "\u{EB8A}"; +pub const HEART_BREAK: &'static str = "\u{EB8B}"; +pub const HEART_HALF: &'static str = "\u{EB8C}"; +pub const HEART_STRAIGHT: &'static str = "\u{EB8D}"; +pub const HEART_STRAIGHT_BREAK: &'static str = "\u{EB8E}"; +pub const HEXAGON: &'static str = "\u{EB8F}"; +pub const HIGH_HEEL: &'static str = "\u{EB90}"; +pub const HIGHLIGHTER_CIRCLE: &'static str = "\u{EB91}"; +pub const HOODIE: &'static str = "\u{EB92}"; +pub const HORSE: &'static str = "\u{EB93}"; +pub const HOURGLASS: &'static str = "\u{EB94}"; +pub const HOURGLASS_HIGH: &'static str = "\u{EB95}"; +pub const HOURGLASS_LOW: &'static str = "\u{EB96}"; +pub const HOURGLASS_MEDIUM: &'static str = "\u{EB97}"; +pub const HOURGLASS_SIMPLE: &'static str = "\u{EB98}"; +pub const HOURGLASS_SIMPLE_HIGH: &'static str = "\u{EB99}"; +pub const HOURGLASS_SIMPLE_LOW: &'static str = "\u{EB9A}"; +pub const HOURGLASS_SIMPLE_MEDIUM: &'static str = "\u{EB9B}"; +pub const HOUSE: &'static str = "\u{EB9C}"; +pub const HOUSE_LINE: &'static str = "\u{EB9D}"; +pub const HOUSE_SIMPLE: &'static str = "\u{EB9E}"; +pub const ICE_CREAM: &'static str = "\u{EB9F}"; +pub const IDENTIFICATION_BADGE: &'static str = "\u{EBA0}"; +pub const IDENTIFICATION_CARD: &'static str = "\u{EBA1}"; +pub const IMAGE: &'static str = "\u{EBA2}"; +pub const IMAGES: &'static str = "\u{EBA3}"; +pub const IMAGE_SQUARE: &'static str = "\u{EBA4}"; +pub const IMAGES_SQUARE: &'static str = "\u{EBA5}"; +pub const INFINITY: &'static str = "\u{EBA6}"; +pub const INFO: &'static str = "\u{EBA7}"; +pub const INSTAGRAM_LOGO: &'static str = "\u{EBA8}"; +pub const INTERSECT: &'static str = "\u{EBA9}"; +pub const INTERSECT_SQUARE: &'static str = "\u{EBAA}"; +pub const INTERSECT_THREE: &'static str = "\u{EBAB}"; +pub const JEEP: &'static str = "\u{EBAC}"; +pub const KANBAN: &'static str = "\u{EBAD}"; +pub const KEY: &'static str = "\u{EBAE}"; +pub const KEYBOARD: &'static str = "\u{EBAF}"; +pub const KEYHOLE: &'static str = "\u{EBB0}"; +pub const KEY_RETURN: &'static str = "\u{EBB1}"; +pub const KNIFE: &'static str = "\u{EBB2}"; +pub const LADDER: &'static str = "\u{EBB3}"; +pub const LADDER_SIMPLE: &'static str = "\u{EBB4}"; +pub const LAMP: &'static str = "\u{EBB5}"; +pub const LAPTOP: &'static str = "\u{EBB6}"; +pub const LAYOUT: &'static str = "\u{EBB7}"; +pub const LEAF: &'static str = "\u{EBB8}"; +pub const LIFEBUOY: &'static str = "\u{EBB9}"; +pub const LIGHTBULB: &'static str = "\u{EBBA}"; +pub const LIGHTBULB_FILAMENT: &'static str = "\u{EBBB}"; +pub const LIGHTHOUSE: &'static str = "\u{EBBC}"; +pub const LIGHTNING: &'static str = "\u{EBBD}"; +pub const LIGHTNING_A: &'static str = "\u{EBBE}"; +pub const LIGHTNING_SLASH: &'static str = "\u{EBBF}"; +pub const LINE_SEGMENT: &'static str = "\u{EBC0}"; +pub const LINE_SEGMENTS: &'static str = "\u{EBC1}"; +pub const LINK: &'static str = "\u{EBC2}"; +pub const LINK_BREAK: &'static str = "\u{EBC3}"; +pub const LINKEDIN_LOGO: &'static str = "\u{EBC4}"; +pub const LINK_SIMPLE: &'static str = "\u{EBC5}"; +pub const LINK_SIMPLE_BREAK: &'static str = "\u{EBC6}"; +pub const LINK_SIMPLE_HORIZONTAL: &'static str = "\u{EBC7}"; +pub const LINK_SIMPLE_HORIZONTAL_BREAK: &'static str = "\u{EBC8}"; +pub const LINUX_LOGO: &'static str = "\u{EBC9}"; +pub const LIST: &'static str = "\u{EBCA}"; +pub const LIST_BULLETS: &'static str = "\u{EBCB}"; +pub const LIST_CHECKS: &'static str = "\u{EBCC}"; +pub const LIST_DASHES: &'static str = "\u{EBCD}"; +pub const LIST_MAGNIFYING_GLASS: &'static str = "\u{EBCE}"; +pub const LIST_NUMBERS: &'static str = "\u{EBCF}"; +pub const LIST_PLUS: &'static str = "\u{EBD0}"; +pub const LOCK: &'static str = "\u{EBD1}"; +pub const LOCKERS: &'static str = "\u{EBD2}"; +pub const LOCK_KEY: &'static str = "\u{EBD3}"; +pub const LOCK_KEY_OPEN: &'static str = "\u{EBD4}"; +pub const LOCK_LAMINATED: &'static str = "\u{EBD5}"; +pub const LOCK_LAMINATED_OPEN: &'static str = "\u{EBD6}"; +pub const LOCK_OPEN: &'static str = "\u{EBD7}"; +pub const LOCK_SIMPLE: &'static str = "\u{EBD8}"; +pub const LOCK_SIMPLE_OPEN: &'static str = "\u{EBD9}"; +pub const MAGIC_WAND: &'static str = "\u{EBDA}"; +pub const MAGNET: &'static str = "\u{EBDB}"; +pub const MAGNET_STRAIGHT: &'static str = "\u{EBDC}"; +pub const MAGNIFYING_GLASS: &'static str = "\u{EBDD}"; +pub const MAGNIFYING_GLASS_MINUS: &'static str = "\u{EBDE}"; +pub const MAGNIFYING_GLASS_PLUS: &'static str = "\u{EBDF}"; +pub const MAP_PIN: &'static str = "\u{EBE0}"; +pub const MAP_PIN_LINE: &'static str = "\u{EBE1}"; +pub const MAP_TRIFOLD: &'static str = "\u{EBE2}"; +pub const MARKER_CIRCLE: &'static str = "\u{EBE3}"; +pub const MARTINI: &'static str = "\u{EBE4}"; +pub const MASK_HAPPY: &'static str = "\u{EBE5}"; +pub const MASK_SAD: &'static str = "\u{EBE6}"; +pub const MATH_OPERATIONS: &'static str = "\u{EBE7}"; +pub const MEDAL: &'static str = "\u{EBE8}"; +pub const MEDAL_MILITARY: &'static str = "\u{EBE9}"; +pub const MEDIUM_LOGO: &'static str = "\u{EBEA}"; +pub const MEGAPHONE: &'static str = "\u{EBEB}"; +pub const MEGAPHONE_SIMPLE: &'static str = "\u{EBEC}"; +pub const MESSENGER_LOGO: &'static str = "\u{EBED}"; +pub const META_LOGO: &'static str = "\u{EBEE}"; +pub const METRONOME: &'static str = "\u{EBEF}"; +pub const MICROPHONE: &'static str = "\u{EBF0}"; +pub const MICROPHONE_SLASH: &'static str = "\u{EBF1}"; +pub const MICROPHONE_STAGE: &'static str = "\u{EBF2}"; +pub const MICROSOFT_EXCEL_LOGO: &'static str = "\u{EBF3}"; +pub const MICROSOFT_OUTLOOK_LOGO: &'static str = "\u{EBF4}"; +pub const MICROSOFT_POWERPOINT_LOGO: &'static str = "\u{EBF5}"; +pub const MICROSOFT_TEAMS_LOGO: &'static str = "\u{EBF6}"; +pub const MICROSOFT_WORD_LOGO: &'static str = "\u{EBF7}"; +pub const MINUS: &'static str = "\u{EBF8}"; +pub const MINUS_CIRCLE: &'static str = "\u{EBF9}"; +pub const MINUS_SQUARE: &'static str = "\u{EBFA}"; +pub const MONEY: &'static str = "\u{EBFB}"; +pub const MONITOR: &'static str = "\u{EBFC}"; +pub const MONITOR_PLAY: &'static str = "\u{EBFD}"; +pub const MOON: &'static str = "\u{EBFE}"; +pub const MOON_STARS: &'static str = "\u{EBFF}"; +pub const MOPED: &'static str = "\u{EC00}"; +pub const MOPED_FRONT: &'static str = "\u{EC01}"; +pub const MOSQUE: &'static str = "\u{EC02}"; +pub const MOTORCYCLE: &'static str = "\u{EC03}"; +pub const MOUNTAINS: &'static str = "\u{EC04}"; +pub const MOUSE: &'static str = "\u{EC05}"; +pub const MOUSE_SIMPLE: &'static str = "\u{EC06}"; +pub const MUSIC_NOTE: &'static str = "\u{EC07}"; +pub const MUSIC_NOTES: &'static str = "\u{EC08}"; +pub const MUSIC_NOTE_SIMPLE: &'static str = "\u{EC09}"; +pub const MUSIC_NOTES_PLUS: &'static str = "\u{EC0A}"; +pub const MUSIC_NOTES_SIMPLE: &'static str = "\u{EC0B}"; +pub const NAVIGATION_ARROW: &'static str = "\u{EC0C}"; +pub const NEEDLE: &'static str = "\u{EC0D}"; +pub const NEWSPAPER: &'static str = "\u{EC0E}"; +pub const NEWSPAPER_CLIPPING: &'static str = "\u{EC0F}"; +pub const NOTCHES: &'static str = "\u{EC10}"; +pub const NOTE: &'static str = "\u{EC11}"; +pub const NOTE_BLANK: &'static str = "\u{EC12}"; +pub const NOTEBOOK: &'static str = "\u{EC13}"; +pub const NOTEPAD: &'static str = "\u{EC14}"; +pub const NOTE_PENCIL: &'static str = "\u{EC15}"; +pub const NOTIFICATION: &'static str = "\u{EC16}"; +pub const NOTION_LOGO: &'static str = "\u{EC17}"; +pub const NUMBER_CIRCLE_EIGHT: &'static str = "\u{EC18}"; +pub const NUMBER_CIRCLE_FIVE: &'static str = "\u{EC19}"; +pub const NUMBER_CIRCLE_FOUR: &'static str = "\u{EC1A}"; +pub const NUMBER_CIRCLE_NINE: &'static str = "\u{EC1B}"; +pub const NUMBER_CIRCLE_ONE: &'static str = "\u{EC1C}"; +pub const NUMBER_CIRCLE_SEVEN: &'static str = "\u{EC1D}"; +pub const NUMBER_CIRCLE_SIX: &'static str = "\u{EC1E}"; +pub const NUMBER_CIRCLE_THREE: &'static str = "\u{EC1F}"; +pub const NUMBER_CIRCLE_TWO: &'static str = "\u{EC20}"; +pub const NUMBER_CIRCLE_ZERO: &'static str = "\u{EC21}"; +pub const NUMBER_EIGHT: &'static str = "\u{EC22}"; +pub const NUMBER_FIVE: &'static str = "\u{EC23}"; +pub const NUMBER_FOUR: &'static str = "\u{EC24}"; +pub const NUMBER_NINE: &'static str = "\u{EC25}"; +pub const NUMBER_ONE: &'static str = "\u{EC26}"; +pub const NUMBER_SEVEN: &'static str = "\u{EC27}"; +pub const NUMBER_SIX: &'static str = "\u{EC28}"; +pub const NUMBER_SQUARE_EIGHT: &'static str = "\u{EC29}"; +pub const NUMBER_SQUARE_FIVE: &'static str = "\u{EC2A}"; +pub const NUMBER_SQUARE_FOUR: &'static str = "\u{EC2B}"; +pub const NUMBER_SQUARE_NINE: &'static str = "\u{EC2C}"; +pub const NUMBER_SQUARE_ONE: &'static str = "\u{EC2D}"; +pub const NUMBER_SQUARE_SEVEN: &'static str = "\u{EC2E}"; +pub const NUMBER_SQUARE_SIX: &'static str = "\u{EC2F}"; +pub const NUMBER_SQUARE_THREE: &'static str = "\u{EC30}"; +pub const NUMBER_SQUARE_TWO: &'static str = "\u{EC31}"; +pub const NUMBER_SQUARE_ZERO: &'static str = "\u{EC32}"; +pub const NUMBER_THREE: &'static str = "\u{EC33}"; +pub const NUMBER_TWO: &'static str = "\u{EC34}"; +pub const NUMBER_ZERO: &'static str = "\u{EC35}"; +pub const NUT: &'static str = "\u{EC36}"; +pub const NY_TIMES_LOGO: &'static str = "\u{EC37}"; +pub const OCTAGON: &'static str = "\u{EC38}"; +pub const OFFICE_CHAIR: &'static str = "\u{EC39}"; +pub const OPTION: &'static str = "\u{EC3A}"; +pub const ORANGE_SLICE: &'static str = "\u{EC3B}"; +pub const PACKAGE: &'static str = "\u{EC3C}"; +pub const PAINT_BRUSH: &'static str = "\u{EC3D}"; +pub const PAINT_BRUSH_BROAD: &'static str = "\u{EC3E}"; +pub const PAINT_BRUSH_HOUSEHOLD: &'static str = "\u{EC3F}"; +pub const PAINT_BUCKET: &'static str = "\u{EC40}"; +pub const PAINT_ROLLER: &'static str = "\u{EC41}"; +pub const PALETTE: &'static str = "\u{EC42}"; +pub const PANTS: &'static str = "\u{EC43}"; +pub const PAPERCLIP: &'static str = "\u{EC44}"; +pub const PAPERCLIP_HORIZONTAL: &'static str = "\u{EC45}"; +pub const PAPER_PLANE: &'static str = "\u{EC46}"; +pub const PAPER_PLANE_RIGHT: &'static str = "\u{EC47}"; +pub const PAPER_PLANE_TILT: &'static str = "\u{EC48}"; +pub const PARACHUTE: &'static str = "\u{EC49}"; +pub const PARAGRAPH: &'static str = "\u{EC4A}"; +pub const PARALLELOGRAM: &'static str = "\u{EC4B}"; +pub const PARK: &'static str = "\u{EC4C}"; +pub const PASSWORD: &'static str = "\u{EC4D}"; +pub const PATH: &'static str = "\u{EC4E}"; +pub const PATREON_LOGO: &'static str = "\u{EC4F}"; +pub const PAUSE: &'static str = "\u{EC50}"; +pub const PAUSE_CIRCLE: &'static str = "\u{EC51}"; +pub const PAW_PRINT: &'static str = "\u{EC52}"; +pub const PAYPAL_LOGO: &'static str = "\u{EC53}"; +pub const PEACE: &'static str = "\u{EC54}"; +pub const PEN: &'static str = "\u{EC55}"; +pub const PENCIL: &'static str = "\u{EC56}"; +pub const PENCIL_CIRCLE: &'static str = "\u{EC57}"; +pub const PENCIL_LINE: &'static str = "\u{EC58}"; +pub const PENCIL_SIMPLE: &'static str = "\u{EC59}"; +pub const PENCIL_SIMPLE_LINE: &'static str = "\u{EC5A}"; +pub const PENCIL_SIMPLE_SLASH: &'static str = "\u{EC5B}"; +pub const PENCIL_SLASH: &'static str = "\u{EC5C}"; +pub const PEN_NIB: &'static str = "\u{EC5D}"; +pub const PEN_NIB_STRAIGHT: &'static str = "\u{EC5E}"; +pub const PENTAGRAM: &'static str = "\u{EC5F}"; +pub const PEPPER: &'static str = "\u{EC60}"; +pub const PERCENT: &'static str = "\u{EC61}"; +pub const PERSON: &'static str = "\u{EC62}"; +pub const PERSON_ARMS_SPREAD: &'static str = "\u{EC63}"; +pub const PERSON_SIMPLE: &'static str = "\u{EC64}"; +pub const PERSON_SIMPLE_BIKE: &'static str = "\u{EC65}"; +pub const PERSON_SIMPLE_RUN: &'static str = "\u{EC66}"; +pub const PERSON_SIMPLE_THROW: &'static str = "\u{EC67}"; +pub const PERSON_SIMPLE_WALK: &'static str = "\u{EC68}"; +pub const PERSPECTIVE: &'static str = "\u{EC69}"; +pub const PHONE: &'static str = "\u{EC6A}"; +pub const PHONE_CALL: &'static str = "\u{EC6B}"; +pub const PHONE_DISCONNECT: &'static str = "\u{EC6C}"; +pub const PHONE_INCOMING: &'static str = "\u{EC6D}"; +pub const PHONE_OUTGOING: &'static str = "\u{EC6E}"; +pub const PHONE_PLUS: &'static str = "\u{EC6F}"; +pub const PHONE_SLASH: &'static str = "\u{EC70}"; +pub const PHONE_X: &'static str = "\u{EC71}"; +pub const PHOSPHOR_LOGO: &'static str = "\u{EC72}"; +pub const PI: &'static str = "\u{EC73}"; +pub const PIANO_KEYS: &'static str = "\u{EC74}"; +pub const PICTURE_IN_PICTURE: &'static str = "\u{EC75}"; +pub const PIGGY_BANK: &'static str = "\u{EC76}"; +pub const PILL: &'static str = "\u{EC77}"; +pub const PINTEREST_LOGO: &'static str = "\u{EC78}"; +pub const PINWHEEL: &'static str = "\u{EC79}"; +pub const PIZZA: &'static str = "\u{EC7A}"; +pub const PLACEHOLDER: &'static str = "\u{EC7B}"; +pub const PLANET: &'static str = "\u{EC7C}"; +pub const PLANT: &'static str = "\u{EC7D}"; +pub const PLAY: &'static str = "\u{EC7E}"; +pub const PLAY_CIRCLE: &'static str = "\u{EC7F}"; +pub const PLAYLIST: &'static str = "\u{EC80}"; +pub const PLAY_PAUSE: &'static str = "\u{EC81}"; +pub const PLUG: &'static str = "\u{EC82}"; +pub const PLUG_CHARGING: &'static str = "\u{EC83}"; +pub const PLUGS: &'static str = "\u{EC84}"; +pub const PLUGS_CONNECTED: &'static str = "\u{EC85}"; +pub const PLUS: &'static str = "\u{EC86}"; +pub const PLUS_CIRCLE: &'static str = "\u{EC87}"; +pub const PLUS_MINUS: &'static str = "\u{EC88}"; +pub const PLUS_SQUARE: &'static str = "\u{EC89}"; +pub const POKER_CHIP: &'static str = "\u{EC8A}"; +pub const POLICE_CAR: &'static str = "\u{EC8B}"; +pub const POLYGON: &'static str = "\u{EC8C}"; +pub const POPCORN: &'static str = "\u{EC8D}"; +pub const POTTED_PLANT: &'static str = "\u{EC8E}"; +pub const POWER: &'static str = "\u{EC8F}"; +pub const PRESCRIPTION: &'static str = "\u{EC90}"; +pub const PRESENTATION: &'static str = "\u{EC91}"; +pub const PRESENTATION_CHART: &'static str = "\u{EC92}"; +pub const PRINTER: &'static str = "\u{EC93}"; +pub const PROHIBIT: &'static str = "\u{EC94}"; +pub const PROHIBIT_INSET: &'static str = "\u{EC95}"; +pub const PROJECTOR_SCREEN: &'static str = "\u{EC96}"; +pub const PROJECTOR_SCREEN_CHART: &'static str = "\u{EC97}"; +pub const PULSE: &'static str = "\u{EC98}"; +pub const ACTIVITY: &'static str = "\u{EC98}"; +pub const PUSH_PIN: &'static str = "\u{EC99}"; +pub const PUSH_PIN_SIMPLE: &'static str = "\u{EC9A}"; +pub const PUSH_PIN_SIMPLE_SLASH: &'static str = "\u{EC9B}"; +pub const PUSH_PIN_SLASH: &'static str = "\u{EC9C}"; +pub const PUZZLE_PIECE: &'static str = "\u{EC9D}"; +pub const QR_CODE: &'static str = "\u{EC9E}"; +pub const QUESTION: &'static str = "\u{EC9F}"; +pub const QUEUE: &'static str = "\u{ECA0}"; +pub const QUOTES: &'static str = "\u{ECA1}"; +pub const RADICAL: &'static str = "\u{ECA2}"; +pub const RADIO: &'static str = "\u{ECA3}"; +pub const RADIOACTIVE: &'static str = "\u{ECA4}"; +pub const RADIO_BUTTON: &'static str = "\u{ECA5}"; +pub const RAINBOW: &'static str = "\u{ECA6}"; +pub const RAINBOW_CLOUD: &'static str = "\u{ECA7}"; +pub const READ_CV_LOGO: &'static str = "\u{ECA8}"; +pub const RECEIPT: &'static str = "\u{ECA9}"; +pub const RECEIPT_X: &'static str = "\u{ECAA}"; +pub const RECORD: &'static str = "\u{ECAB}"; +pub const RECTANGLE: &'static str = "\u{ECAC}"; +pub const RECYCLE: &'static str = "\u{ECAD}"; +pub const REDDIT_LOGO: &'static str = "\u{ECAE}"; +pub const REPEAT: &'static str = "\u{ECAF}"; +pub const REPEAT_ONCE: &'static str = "\u{ECB0}"; +pub const REWIND: &'static str = "\u{ECB1}"; +pub const REWIND_CIRCLE: &'static str = "\u{ECB2}"; +pub const ROAD_HORIZON: &'static str = "\u{ECB3}"; +pub const ROBOT: &'static str = "\u{ECB4}"; +pub const ROCKET: &'static str = "\u{ECB5}"; +pub const ROCKET_LAUNCH: &'static str = "\u{ECB6}"; +pub const ROWS: &'static str = "\u{ECB7}"; +pub const RSS: &'static str = "\u{ECB8}"; +pub const RSS_SIMPLE: &'static str = "\u{ECB9}"; +pub const RUG: &'static str = "\u{ECBA}"; +pub const RULER: &'static str = "\u{ECBB}"; +pub const SCALES: &'static str = "\u{ECBC}"; +pub const SCAN: &'static str = "\u{ECBD}"; +pub const SCISSORS: &'static str = "\u{ECBE}"; +pub const SCOOTER: &'static str = "\u{ECBF}"; +pub const SCREENCAST: &'static str = "\u{ECC0}"; +pub const SCRIBBLE_LOOP: &'static str = "\u{ECC1}"; +pub const SCROLL: &'static str = "\u{ECC2}"; +pub const SEAL: &'static str = "\u{ECC3}"; +pub const CIRCLE_WAVY: &'static str = "\u{ECC3}"; +pub const SEAL_CHECK: &'static str = "\u{ECC4}"; +pub const CIRCLE_WAVY_CHECK: &'static str = "\u{ECC4}"; +pub const SEAL_QUESTION: &'static str = "\u{ECC5}"; +pub const CIRCLE_WAVY_QUESTION: &'static str = "\u{ECC5}"; +pub const SEAL_WARNING: &'static str = "\u{ECC6}"; +pub const CIRCLE_WAVY_WARNING: &'static str = "\u{ECC6}"; +pub const SELECTION: &'static str = "\u{ECC7}"; +pub const SELECTION_ALL: &'static str = "\u{ECC8}"; +pub const SELECTION_BACKGROUND: &'static str = "\u{ECC9}"; +pub const SELECTION_FOREGROUND: &'static str = "\u{ECCA}"; +pub const SELECTION_INVERSE: &'static str = "\u{ECCB}"; +pub const SELECTION_PLUS: &'static str = "\u{ECCC}"; +pub const SELECTION_SLASH: &'static str = "\u{ECCD}"; +pub const SHAPES: &'static str = "\u{ECCE}"; +pub const SHARE: &'static str = "\u{ECCF}"; +pub const SHARE_FAT: &'static str = "\u{ECD0}"; +pub const SHARE_NETWORK: &'static str = "\u{ECD1}"; +pub const SHIELD: &'static str = "\u{ECD2}"; +pub const SHIELD_CHECK: &'static str = "\u{ECD3}"; +pub const SHIELD_CHECKERED: &'static str = "\u{ECD4}"; +pub const SHIELD_CHEVRON: &'static str = "\u{ECD5}"; +pub const SHIELD_PLUS: &'static str = "\u{ECD6}"; +pub const SHIELD_SLASH: &'static str = "\u{ECD7}"; +pub const SHIELD_STAR: &'static str = "\u{ECD8}"; +pub const SHIELD_WARNING: &'static str = "\u{ECD9}"; +pub const SHIRT_FOLDED: &'static str = "\u{ECDA}"; +pub const SHOOTING_STAR: &'static str = "\u{ECDB}"; +pub const SHOPPING_BAG: &'static str = "\u{ECDC}"; +pub const SHOPPING_BAG_OPEN: &'static str = "\u{ECDD}"; +pub const SHOPPING_CART: &'static str = "\u{ECDE}"; +pub const SHOPPING_CART_SIMPLE: &'static str = "\u{ECDF}"; +pub const SHOWER: &'static str = "\u{ECE0}"; +pub const SHRIMP: &'static str = "\u{ECE1}"; +pub const SHUFFLE: &'static str = "\u{ECE2}"; +pub const SHUFFLE_ANGULAR: &'static str = "\u{ECE3}"; +pub const SHUFFLE_SIMPLE: &'static str = "\u{ECE4}"; +pub const SIDEBAR: &'static str = "\u{ECE5}"; +pub const SIDEBAR_SIMPLE: &'static str = "\u{ECE6}"; +pub const SIGMA: &'static str = "\u{ECE7}"; +pub const SIGNATURE: &'static str = "\u{ECE8}"; +pub const SIGN_IN: &'static str = "\u{ECE9}"; +pub const SIGN_OUT: &'static str = "\u{ECEA}"; +pub const SIGNPOST: &'static str = "\u{ECEB}"; +pub const SIM_CARD: &'static str = "\u{ECEC}"; +pub const SIREN: &'static str = "\u{ECED}"; +pub const SKETCH_LOGO: &'static str = "\u{ECEE}"; +pub const SKIP_BACK: &'static str = "\u{ECEF}"; +pub const SKIP_BACK_CIRCLE: &'static str = "\u{ECF0}"; +pub const SKIP_FORWARD: &'static str = "\u{ECF1}"; +pub const SKIP_FORWARD_CIRCLE: &'static str = "\u{ECF2}"; +pub const SKULL: &'static str = "\u{ECF3}"; +pub const SLACK_LOGO: &'static str = "\u{ECF4}"; +pub const SLIDERS: &'static str = "\u{ECF5}"; +pub const SLIDERS_HORIZONTAL: &'static str = "\u{ECF6}"; +pub const SLIDESHOW: &'static str = "\u{ECF7}"; +pub const SMILEY: &'static str = "\u{ECF8}"; +pub const SMILEY_ANGRY: &'static str = "\u{ECF9}"; +pub const SMILEY_BLANK: &'static str = "\u{ECFA}"; +pub const SMILEY_MEH: &'static str = "\u{ECFB}"; +pub const SMILEY_NERVOUS: &'static str = "\u{ECFC}"; +pub const SMILEY_SAD: &'static str = "\u{ECFD}"; +pub const SMILEY_STICKER: &'static str = "\u{ECFE}"; +pub const SMILEY_WINK: &'static str = "\u{ECFF}"; +pub const SMILEY_X_EYES: &'static str = "\u{ED00}"; +pub const SNAPCHAT_LOGO: &'static str = "\u{ED01}"; +pub const SNEAKER: &'static str = "\u{ED02}"; +pub const SNEAKER_MOVE: &'static str = "\u{ED03}"; +pub const SNOWFLAKE: &'static str = "\u{ED04}"; +pub const SOCCER_BALL: &'static str = "\u{ED05}"; +pub const SORT_ASCENDING: &'static str = "\u{ED06}"; +pub const SORT_DESCENDING: &'static str = "\u{ED07}"; +pub const SOUNDCLOUD_LOGO: &'static str = "\u{ED08}"; +pub const SPADE: &'static str = "\u{ED09}"; +pub const SPARKLE: &'static str = "\u{ED0A}"; +pub const SPEAKER_HIFI: &'static str = "\u{ED0B}"; +pub const SPEAKER_HIGH: &'static str = "\u{ED0C}"; +pub const SPEAKER_LOW: &'static str = "\u{ED0D}"; +pub const SPEAKER_NONE: &'static str = "\u{ED0E}"; +pub const SPEAKER_SIMPLE_HIGH: &'static str = "\u{ED0F}"; +pub const SPEAKER_SIMPLE_LOW: &'static str = "\u{ED10}"; +pub const SPEAKER_SIMPLE_NONE: &'static str = "\u{ED11}"; +pub const SPEAKER_SIMPLE_SLASH: &'static str = "\u{ED12}"; +pub const SPEAKER_SIMPLE_X: &'static str = "\u{ED13}"; +pub const SPEAKER_SLASH: &'static str = "\u{ED14}"; +pub const SPEAKER_X: &'static str = "\u{ED15}"; +pub const SPINNER: &'static str = "\u{ED16}"; +pub const SPINNER_GAP: &'static str = "\u{ED17}"; +pub const SPIRAL: &'static str = "\u{ED18}"; +pub const SPLIT_HORIZONTAL: &'static str = "\u{ED19}"; +pub const SPLIT_VERTICAL: &'static str = "\u{ED1A}"; +pub const SPOTIFY_LOGO: &'static str = "\u{ED1B}"; +pub const SQUARE: &'static str = "\u{ED1C}"; +pub const SQUARE_HALF: &'static str = "\u{ED1D}"; +pub const SQUARE_HALF_BOTTOM: &'static str = "\u{ED1E}"; +pub const SQUARE_LOGO: &'static str = "\u{ED1F}"; +pub const SQUARES_FOUR: &'static str = "\u{ED20}"; +pub const SQUARE_SPLIT_HORIZONTAL: &'static str = "\u{ED21}"; +pub const SQUARE_SPLIT_VERTICAL: &'static str = "\u{ED22}"; +pub const STACK: &'static str = "\u{ED23}"; +pub const STACK_OVERFLOW_LOGO: &'static str = "\u{ED24}"; +pub const STACK_SIMPLE: &'static str = "\u{ED25}"; +pub const STAIRS: &'static str = "\u{ED26}"; +pub const STAMP: &'static str = "\u{ED27}"; +pub const STAR: &'static str = "\u{ED28}"; +pub const STAR_AND_CRESCENT: &'static str = "\u{ED29}"; +pub const STAR_FOUR: &'static str = "\u{ED2A}"; +pub const STAR_HALF: &'static str = "\u{ED2B}"; +pub const STAR_OF_DAVID: &'static str = "\u{ED2C}"; +pub const STEERING_WHEEL: &'static str = "\u{ED2D}"; +pub const STEPS: &'static str = "\u{ED2E}"; +pub const STETHOSCOPE: &'static str = "\u{ED2F}"; +pub const STICKER: &'static str = "\u{ED30}"; +pub const STOOL: &'static str = "\u{ED31}"; +pub const STOP: &'static str = "\u{ED32}"; +pub const STOP_CIRCLE: &'static str = "\u{ED33}"; +pub const STOREFRONT: &'static str = "\u{ED34}"; +pub const STRATEGY: &'static str = "\u{ED35}"; +pub const STRIPE_LOGO: &'static str = "\u{ED36}"; +pub const STUDENT: &'static str = "\u{ED37}"; +pub const SUBTITLES: &'static str = "\u{ED38}"; +pub const SUBTRACT: &'static str = "\u{ED39}"; +pub const SUBTRACT_SQUARE: &'static str = "\u{ED3A}"; +pub const SUITCASE: &'static str = "\u{ED3B}"; +pub const SUITCASE_ROLLING: &'static str = "\u{ED3C}"; +pub const SUITCASE_SIMPLE: &'static str = "\u{ED3D}"; +pub const SUN: &'static str = "\u{ED3E}"; +pub const SUN_DIM: &'static str = "\u{ED3F}"; +pub const SUNGLASSES: &'static str = "\u{ED40}"; +pub const SUN_HORIZON: &'static str = "\u{ED41}"; +pub const SWAP: &'static str = "\u{ED42}"; +pub const SWATCHES: &'static str = "\u{ED43}"; +pub const SWIMMING_POOL: &'static str = "\u{ED44}"; +pub const SWORD: &'static str = "\u{ED45}"; +pub const SYNAGOGUE: &'static str = "\u{ED46}"; +pub const SYRINGE: &'static str = "\u{ED47}"; +pub const TABLE: &'static str = "\u{ED48}"; +pub const TABS: &'static str = "\u{ED49}"; +pub const TAG: &'static str = "\u{ED4A}"; +pub const TAG_CHEVRON: &'static str = "\u{ED4B}"; +pub const TAG_SIMPLE: &'static str = "\u{ED4C}"; +pub const TARGET: &'static str = "\u{ED4D}"; +pub const TAXI: &'static str = "\u{ED4E}"; +pub const TELEGRAM_LOGO: &'static str = "\u{ED4F}"; +pub const TELEVISION: &'static str = "\u{ED50}"; +pub const TELEVISION_SIMPLE: &'static str = "\u{ED51}"; +pub const TENNIS_BALL: &'static str = "\u{ED52}"; +pub const TENT: &'static str = "\u{ED53}"; +pub const TERMINAL: &'static str = "\u{ED54}"; +pub const TERMINAL_WINDOW: &'static str = "\u{ED55}"; +pub const TEST_TUBE: &'static str = "\u{ED56}"; +pub const TEXT_AA: &'static str = "\u{ED57}"; +pub const TEXT_ALIGN_CENTER: &'static str = "\u{ED58}"; +pub const TEXT_ALIGN_JUSTIFY: &'static str = "\u{ED59}"; +pub const TEXT_ALIGN_LEFT: &'static str = "\u{ED5A}"; +pub const TEXT_ALIGN_RIGHT: &'static str = "\u{ED5B}"; +pub const TEXT_A_UNDERLINE: &'static str = "\u{ED5C}"; +pub const TEXT_B: &'static str = "\u{ED5D}"; +pub const TEXT_BOLDER: &'static str = "\u{ED5D}"; +pub const TEXTBOX: &'static str = "\u{ED5E}"; +pub const TEXT_COLUMNS: &'static str = "\u{ED5F}"; +pub const TEXT_H: &'static str = "\u{ED60}"; +pub const TEXT_H_FIVE: &'static str = "\u{ED61}"; +pub const TEXT_H_FOUR: &'static str = "\u{ED62}"; +pub const TEXT_H_ONE: &'static str = "\u{ED63}"; +pub const TEXT_H_SIX: &'static str = "\u{ED64}"; +pub const TEXT_H_THREE: &'static str = "\u{ED65}"; +pub const TEXT_H_TWO: &'static str = "\u{ED66}"; +pub const TEXT_INDENT: &'static str = "\u{ED67}"; +pub const TEXT_ITALIC: &'static str = "\u{ED68}"; +pub const TEXT_OUTDENT: &'static str = "\u{ED69}"; +pub const TEXT_STRIKETHROUGH: &'static str = "\u{ED6A}"; +pub const TEXT_T: &'static str = "\u{ED6B}"; +pub const TEXT_UNDERLINE: &'static str = "\u{ED6C}"; +pub const THERMOMETER: &'static str = "\u{ED6D}"; +pub const THERMOMETER_COLD: &'static str = "\u{ED6E}"; +pub const THERMOMETER_HOT: &'static str = "\u{ED6F}"; +pub const THERMOMETER_SIMPLE: &'static str = "\u{ED70}"; +pub const THUMBS_DOWN: &'static str = "\u{ED71}"; +pub const THUMBS_UP: &'static str = "\u{ED72}"; +pub const TICKET: &'static str = "\u{ED73}"; +pub const TIDAL_LOGO: &'static str = "\u{ED74}"; +pub const TIKTOK_LOGO: &'static str = "\u{ED75}"; +pub const TIMER: &'static str = "\u{ED76}"; +pub const TIPI: &'static str = "\u{ED77}"; +pub const TOGGLE_LEFT: &'static str = "\u{ED78}"; +pub const TOGGLE_RIGHT: &'static str = "\u{ED79}"; +pub const TOILET: &'static str = "\u{ED7A}"; +pub const TOILET_PAPER: &'static str = "\u{ED7B}"; +pub const TOOLBOX: &'static str = "\u{ED7C}"; +pub const TOOTH: &'static str = "\u{ED7D}"; +pub const TOTE: &'static str = "\u{ED7E}"; +pub const TOTE_SIMPLE: &'static str = "\u{ED7F}"; +pub const TRADEMARK: &'static str = "\u{ED80}"; +pub const TRADEMARK_REGISTERED: &'static str = "\u{ED81}"; +pub const TRAFFIC_CONE: &'static str = "\u{ED82}"; +pub const TRAFFIC_SIGN: &'static str = "\u{ED83}"; +pub const TRAFFIC_SIGNAL: &'static str = "\u{ED84}"; +pub const TRAIN: &'static str = "\u{ED85}"; +pub const TRAIN_REGIONAL: &'static str = "\u{ED86}"; +pub const TRAIN_SIMPLE: &'static str = "\u{ED87}"; +pub const TRAM: &'static str = "\u{ED88}"; +pub const TRANSLATE: &'static str = "\u{ED89}"; +pub const TRASH: &'static str = "\u{ED8A}"; +pub const TRASH_SIMPLE: &'static str = "\u{ED8B}"; +pub const TRAY: &'static str = "\u{ED8C}"; +pub const TREE: &'static str = "\u{ED8D}"; +pub const TREE_EVERGREEN: &'static str = "\u{ED8E}"; +pub const TREE_PALM: &'static str = "\u{ED8F}"; +pub const TREE_STRUCTURE: &'static str = "\u{ED90}"; +pub const TREND_DOWN: &'static str = "\u{ED91}"; +pub const TREND_UP: &'static str = "\u{ED92}"; +pub const TRIANGLE: &'static str = "\u{ED93}"; +pub const TROPHY: &'static str = "\u{ED94}"; +pub const TRUCK: &'static str = "\u{ED95}"; +pub const T_SHIRT: &'static str = "\u{ED96}"; +pub const TWITCH_LOGO: &'static str = "\u{ED97}"; +pub const TWITTER_LOGO: &'static str = "\u{ED98}"; +pub const UMBRELLA: &'static str = "\u{ED99}"; +pub const UMBRELLA_SIMPLE: &'static str = "\u{ED9A}"; +pub const UNITE: &'static str = "\u{ED9B}"; +pub const UNITE_SQUARE: &'static str = "\u{ED9C}"; +pub const UPLOAD: &'static str = "\u{ED9D}"; +pub const UPLOAD_SIMPLE: &'static str = "\u{ED9E}"; +pub const USB: &'static str = "\u{ED9F}"; +pub const USER: &'static str = "\u{EDA0}"; +pub const USER_CIRCLE: &'static str = "\u{EDA1}"; +pub const USER_CIRCLE_GEAR: &'static str = "\u{EDA2}"; +pub const USER_CIRCLE_MINUS: &'static str = "\u{EDA3}"; +pub const USER_CIRCLE_PLUS: &'static str = "\u{EDA4}"; +pub const USER_FOCUS: &'static str = "\u{EDA5}"; +pub const USER_GEAR: &'static str = "\u{EDA6}"; +pub const USER_LIST: &'static str = "\u{EDA7}"; +pub const USER_MINUS: &'static str = "\u{EDA8}"; +pub const USER_PLUS: &'static str = "\u{EDA9}"; +pub const USER_RECTANGLE: &'static str = "\u{EDAA}"; +pub const USERS: &'static str = "\u{EDAB}"; +pub const USERS_FOUR: &'static str = "\u{EDAC}"; +pub const USER_SQUARE: &'static str = "\u{EDAD}"; +pub const USERS_THREE: &'static str = "\u{EDAE}"; +pub const USER_SWITCH: &'static str = "\u{EDAF}"; +pub const VAN: &'static str = "\u{EDB0}"; +pub const VAULT: &'static str = "\u{EDB1}"; +pub const VIBRATE: &'static str = "\u{EDB2}"; +pub const VIDEO: &'static str = "\u{EDB3}"; +pub const VIDEO_CAMERA: &'static str = "\u{EDB4}"; +pub const VIDEO_CAMERA_SLASH: &'static str = "\u{EDB5}"; +pub const VIGNETTE: &'static str = "\u{EDB6}"; +pub const VINYL_RECORD: &'static str = "\u{EDB7}"; +pub const VIRTUAL_REALITY: &'static str = "\u{EDB8}"; +pub const VIRUS: &'static str = "\u{EDB9}"; +pub const VOICEMAIL: &'static str = "\u{EDBA}"; +pub const VOLLEYBALL: &'static str = "\u{EDBB}"; +pub const WALL: &'static str = "\u{EDBC}"; +pub const WALLET: &'static str = "\u{EDBD}"; +pub const WAREHOUSE: &'static str = "\u{EDBE}"; +pub const WARNING: &'static str = "\u{EDBF}"; +pub const WARNING_CIRCLE: &'static str = "\u{EDC0}"; +pub const WARNING_DIAMOND: &'static str = "\u{EDC1}"; +pub const WARNING_OCTAGON: &'static str = "\u{EDC2}"; +pub const WATCH: &'static str = "\u{EDC3}"; +pub const WAVEFORM: &'static str = "\u{EDC4}"; +pub const WAVES: &'static str = "\u{EDC5}"; +pub const WAVE_SAWTOOTH: &'static str = "\u{EDC6}"; +pub const WAVE_SINE: &'static str = "\u{EDC7}"; +pub const WAVE_SQUARE: &'static str = "\u{EDC8}"; +pub const WAVE_TRIANGLE: &'static str = "\u{EDC9}"; +pub const WEBCAM: &'static str = "\u{EDCA}"; +pub const WEBCAM_SLASH: &'static str = "\u{EDCB}"; +pub const WEBHOOKS_LOGO: &'static str = "\u{EDCC}"; +pub const WECHAT_LOGO: &'static str = "\u{EDCD}"; +pub const WHATSAPP_LOGO: &'static str = "\u{EDCE}"; +pub const WHEELCHAIR: &'static str = "\u{EDCF}"; +pub const WHEELCHAIR_MOTION: &'static str = "\u{EDD0}"; +pub const WIFI_HIGH: &'static str = "\u{EDD1}"; +pub const WIFI_LOW: &'static str = "\u{EDD2}"; +pub const WIFI_MEDIUM: &'static str = "\u{EDD3}"; +pub const WIFI_NONE: &'static str = "\u{EDD4}"; +pub const WIFI_SLASH: &'static str = "\u{EDD5}"; +pub const WIFI_X: &'static str = "\u{EDD6}"; +pub const WIND: &'static str = "\u{EDD7}"; +pub const WINDOWS_LOGO: &'static str = "\u{EDD8}"; +pub const WINE: &'static str = "\u{EDD9}"; +pub const WRENCH: &'static str = "\u{EDDA}"; +pub const X: &'static str = "\u{EDDB}"; +pub const X_CIRCLE: &'static str = "\u{EDDC}"; +pub const X_SQUARE: &'static str = "\u{EDDD}"; +pub const YIN_YANG: &'static str = "\u{EDDE}"; +pub const YOUTUBE_LOGO: &'static str = "\u{EDDF}"; diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 00000000..79623d92 --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,25 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +mod app; +pub use app::App; + +mod colors; +pub use colors::Colors; + +pub mod theme; + +pub mod icons; +pub mod platform; +pub mod views; diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs new file mode 100644 index 00000000..cc31c4c2 --- /dev/null +++ b/src/gui/platform/android/mod.rs @@ -0,0 +1,307 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use lazy_static::lazy_static; +use parking_lot::RwLock; +use std::env; +use std::ffi::OsString; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; + +use jni::JNIEnv; +use jni::objects::{JByteArray, JObject, JString, JValue}; +use winit::platform::android::activity::AndroidApp; + +use crate::gui::platform::PlatformCallbacks; + +/// Android platform implementation. +#[derive(Clone)] +pub struct Android { + /// Android related state. + android_app: AndroidApp, + + /// Context to repaint content and handle viewport commands. + ctx: Arc>>, +} + +impl Android { + /// Create new Android platform instance from provided [`AndroidApp`]. + pub fn new(app: AndroidApp) -> Self { + Self { + android_app: app, + ctx: Arc::new(RwLock::new(None)), + } + } + + /// Call Android Activity method with JNI. + pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option { + let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); + let mut env = vm.attach_current_thread().unwrap(); + let activity = + unsafe { JObject::from_raw(self.android_app.activity_as_ptr() as jni::sys::jobject) }; + if let Ok(result) = env.call_method(activity, name, s, a) { + return Some(result.as_jni().clone()); + } + None + } +} + +impl PlatformCallbacks for Android { + fn set_context(&mut self, ctx: &egui::Context) { + let mut w_ctx = self.ctx.write(); + *w_ctx = Some(ctx.clone()); + } + + fn exit(&self) { + let _ = self.call_java_method("exit", "()V", &[]); + } + + fn copy_string_to_buffer(&self, data: String) { + let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); + let env = vm.attach_current_thread().unwrap(); + let arg_value = env.new_string(data).unwrap(); + let _ = self.call_java_method( + "copyText", + "(Ljava/lang/String;)V", + &[JValue::Object(&JObject::from(arg_value))], + ); + } + + fn get_string_from_buffer(&self) -> String { + let result = self + .call_java_method("pasteText", "()Ljava/lang/String;", &[]) + .unwrap(); + let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); + let mut env = vm.attach_current_thread().unwrap(); + let j_object: jni::sys::jobject = unsafe { result.l }; + let paste_data: String = unsafe { + env.get_string(JString::from(JObject::from_raw(j_object)).as_ref()) + .unwrap() + .into() + }; + paste_data + } + + fn start_camera(&self) { + // Clear image. + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = None; + // Start camera. + let _ = self.call_java_method("startCamera", "()V", &[]); + } + + fn stop_camera(&self) { + // Stop camera. + let _ = self.call_java_method("stopCamera", "()V", &[]); + // Clear image. + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = None; + } + + fn camera_image(&self) -> Option<(Vec, u32)> { + let r_image = LAST_CAMERA_IMAGE.read(); + if r_image.is_some() { + return Some(r_image.clone().unwrap()); + } + None + } + + fn can_switch_camera(&self) -> bool { + if let Some(res) = self.call_java_method("camerasAmount", "()I", &[]) { + let amount = unsafe { res.i }; + return amount > 1; + } + false + } + + fn switch_camera(&self) { + let _ = self.call_java_method("switchCamera", "()V", &[]); + } + + fn share_data(&self, name: String, data: Vec) -> Result<(), std::io::Error> { + let default_cache = OsString::from(dirs::cache_dir().unwrap()); + let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache)); + // File path for Android provider. + file.push("share"); + if !file.exists() { + std::fs::create_dir(file.clone())?; + } + file.push(name); + if file.exists() { + std::fs::remove_file(file.clone())?; + } + let mut image = File::create_new(file.clone())?; + image.write_all(data.as_slice())?; + image.sync_all()?; + // Call share modal at system. + let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); + let env = vm.attach_current_thread().unwrap(); + let arg_value = env.new_string(file.to_str().unwrap()).unwrap(); + let _ = self.call_java_method( + "shareData", + "(Ljava/lang/String;)V", + &[JValue::Object(&JObject::from(arg_value))], + ); + Ok(()) + } + + fn save_file(&self, name: String, data: Vec) -> Result<(), std::io::Error> { + // Stage the bytes in the share cache (same dir the FileProvider exposes), + // then let Java copy them to the user-chosen Storage Access Framework + // document. Mirrors `share_data`, but the Java side uses CREATE_DOCUMENT. + let default_cache = OsString::from(dirs::cache_dir().unwrap()); + let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache)); + file.push("share"); + if !file.exists() { + std::fs::create_dir(file.clone())?; + } + file.push(&name); + if file.exists() { + std::fs::remove_file(file.clone())?; + } + let mut f = File::create_new(file.clone())?; + f.write_all(data.as_slice())?; + f.sync_all()?; + let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); + let env = vm.attach_current_thread().unwrap(); + let path_arg = env.new_string(file.to_str().unwrap()).unwrap(); + let name_arg = env.new_string(&name).unwrap(); + let _ = self.call_java_method( + "saveFile", + "(Ljava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Object(&JObject::from(path_arg)), + JValue::Object(&JObject::from(name_arg)), + ], + ); + Ok(()) + } + + fn share_text(&self, text: String) { + let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); + let env = vm.attach_current_thread().unwrap(); + let Ok(arg_value) = env.new_string(text) else { + return; + }; + let _ = self.call_java_method( + "shareText", + "(Ljava/lang/String;)V", + &[JValue::Object(&JObject::from(arg_value))], + ); + } + + fn pick_file(&self) -> Option { + // Clear previous result. + let mut w_path = PICKED_FILE_PATH.write(); + *w_path = None; + // Launch file picker. + let _ = self.call_java_method("pickFile", "()V", &[]); + // Return empty string to identify async pick. + Some("".to_string()) + } + + fn pick_folder(&self) -> Option { + // Clear previous result. + let mut w_path = PICKED_FILE_PATH.write(); + *w_path = None; + // Launch file picker. + let _ = self.call_java_method("pickFolder", "()V", &[]); + // Return empty string to identify async pick. + Some("".to_string()) + } + + fn picked_file(&self) -> Option { + let has_file = { + let r_path = PICKED_FILE_PATH.read(); + r_path.is_some() + }; + if has_file { + let mut w_path = PICKED_FILE_PATH.write(); + let path = Some(w_path.clone().unwrap()); + *w_path = None; + return path; + } + None + } + + fn request_user_attention(&self) {} + + fn user_attention_required(&self) -> bool { + false + } + + fn clear_user_attention(&self) {} + + fn set_status_bar_white_icons(&self, white: bool) { + self.call_java_method( + "setStatusBarWhiteIcons", + "(Z)V", + &[JValue::Bool(white as u8)], + ); + } + + fn vibrate_error(&self) { + let _ = self.call_java_method("vibrateError", "()V", &[]); + } + + fn vibrate_copy(&self) { + let _ = self.call_java_method("vibrateCopy", "()V", &[]); + } +} + +lazy_static! { + /// Last image data from camera. + static ref LAST_CAMERA_IMAGE: Arc, u32)>>> = Arc::new(RwLock::new(None)); + /// Picked file path. + static ref PICKED_FILE_PATH: Arc>> = Arc::new(RwLock::new(None)); +} + +/// Callback from Java code with last entered character from soft keyboard. +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage( + env: JNIEnv, + _class: JObject, + buff: jni::sys::jbyteArray, + rotation: jni::sys::jint, +) { + let arr = unsafe { JByteArray::from_raw(buff) }; + let image: Vec = env.convert_byte_array(arr).unwrap(); + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = Some((image, rotation as u32)); +} + +/// Callback from Java code with picked file path. +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +pub extern "C" fn Java_mw_gri_android_MainActivity_onFilePick( + _env: JNIEnv, + _class: JObject, + char: jni::sys::jstring, +) { + use std::ops::Add; + unsafe { + let j_obj = JString::from_raw(char); + let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap(); + match j_str.to_str() { + Ok(str) => { + let mut w_path = PICKED_FILE_PATH.write(); + *w_path = Some(w_path.clone().unwrap_or("".to_string()).add(str)); + } + Err(_) => {} + } + } +} diff --git a/src/gui/platform/desktop/mod.rs b/src/gui/platform/desktop/mod.rs new file mode 100644 index 00000000..74380eaf --- /dev/null +++ b/src/gui/platform/desktop/mod.rs @@ -0,0 +1,361 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::{UserAttentionType, ViewportCommand, WindowLevel}; +use lazy_static::lazy_static; +use parking_lot::RwLock; +use rfd::FileDialog; +use std::fs::File; +use std::io::Write; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::thread; + +use crate::gui::platform::PlatformCallbacks; + +/// Desktop platform related actions. +#[derive(Clone)] +pub struct Desktop { + /// Context to repaint content and handle viewport commands. + ctx: Arc>>, + + /// Cameras amount. + cameras_amount: Arc, + /// Camera index. + camera_index: Arc, + /// Flag to check if camera stop is needed. + stop_camera: Arc, + + /// Flag to check if attention required after window focusing. + attention_required: Arc, +} + +impl Desktop { + pub fn new() -> Self { + Self { + ctx: Arc::new(RwLock::new(None)), + cameras_amount: Arc::new(AtomicUsize::new(0)), + camera_index: Arc::new(AtomicUsize::new(0)), + stop_camera: Arc::new(AtomicBool::new(false)), + attention_required: Arc::new(AtomicBool::new(false)), + } + } + + // #[allow(dead_code)] + #[cfg(not(target_os = "macos"))] + fn start_camera_capture( + cameras_amount: Arc, + camera_index: Arc, + stop_camera: Arc, + ) { + use nokhwa::Camera; + use nokhwa::pixel_format::RgbFormat; + use nokhwa::utils::ApiBackend; + use nokhwa::utils::{FrameFormat, RequestedFormat, RequestedFormatType}; + + thread::spawn(move || { + // Device enumeration does IO — keep it off the UI thread, and + // treat a backend error the same as "no cameras". + let devices = nokhwa::query(ApiBackend::Auto).unwrap_or_default(); + cameras_amount.store(devices.len(), Ordering::Relaxed); + let index = camera_index.load(Ordering::Relaxed); + if devices.is_empty() || index >= devices.len() { + return; + } + + // Open by the enumerated device's own index, not the list + // position: on v4l they differ whenever /dev/video0 is absent + // (first camera at video1, loopback-only setups, …). + let index = devices[index].index().clone(); + let requested = + RequestedFormat::new::(RequestedFormatType::AbsoluteHighestFrameRate); + // Create and open camera. + if let Ok(mut camera) = Camera::new(index, requested) { + if let Ok(_) = camera.open_stream() { + loop { + // Stop if camera was stopped. + if stop_camera.load(Ordering::Relaxed) { + stop_camera.store(false, Ordering::Relaxed); + // Clear image. + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = None; + break; + } + // Get a frame. + if let Ok(frame) = camera.frame() { + // Consumers expect an encoded image. MJPEG frames + // already are; anything else (YUYV, NV12, …) must + // be decoded to RGB and re-encoded, or the readers + // fail on the raw buffer and show a spinner forever. + let bytes = if frame.source_frame_format() == FrameFormat::MJPEG { + Some(frame.buffer().to_vec()) + } else if let Ok(image) = frame.decode_image::() { + let mut bytes: Vec = Vec::new(); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Jpeg, + ) + .ok() + .map(|_| bytes) + } else { + None + }; + if let Some(bytes) = bytes { + // Save image. + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = Some((bytes, 0)); + } + } else { + // Clear image. + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = None; + break; + } + } + let _ = camera.stop_stream(); + }; + } + }); + } + + #[allow(dead_code)] + #[cfg(target_os = "macos")] + fn start_camera_capture( + cameras_amount: Arc, + camera_index: Arc, + stop_camera: Arc, + ) { + use nokhwa::CallbackCamera; + use nokhwa::nokhwa_initialize; + use nokhwa::pixel_format::RgbFormat; + use nokhwa::query; + use nokhwa::utils::ApiBackend; + use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType}; + + // Ask permission to open camera. + nokhwa_initialize(|_| {}); + + thread::spawn(move || { + let cameras = query(ApiBackend::Auto).unwrap(); + cameras_amount.store(cameras.len(), Ordering::Relaxed); + let index = camera_index.load(Ordering::Relaxed); + if cameras.is_empty() || index >= cameras.len() { + return; + } + // Start camera. + let camera_index = CameraIndex::Index(camera_index.load(Ordering::Relaxed) as u32); + let camera_callback = CallbackCamera::new( + camera_index, + RequestedFormat::new::(RequestedFormatType::AbsoluteHighestFrameRate), + |_| {}, + ); + if let Ok(mut cb) = camera_callback { + if cb.open_stream().is_ok() { + loop { + // Stop if camera was stopped. + if stop_camera.load(Ordering::Relaxed) { + stop_camera.store(false, Ordering::Relaxed); + // Clear image. + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = None; + break; + } + // Get image from camera. + if let Ok(frame) = cb.poll_frame() { + let image = frame.decode_image::().unwrap(); + let mut bytes: Vec = Vec::new(); + let format = image::ImageFormat::Jpeg; + // Convert image to Jpeg format. + image + .write_to(&mut std::io::Cursor::new(&mut bytes), format) + .unwrap(); + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = Some((bytes, 0)); + } else { + // Clear image. + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = None; + break; + } + } + } + } + }); + } +} + +impl PlatformCallbacks for Desktop { + fn set_context(&mut self, ctx: &egui::Context) { + let mut w_ctx = self.ctx.write(); + *w_ctx = Some(ctx.clone()); + } + + fn exit(&self) { + let r_ctx = self.ctx.read(); + if r_ctx.is_some() { + let ctx = r_ctx.as_ref().unwrap(); + ctx.send_viewport_cmd(ViewportCommand::Close); + } + } + + fn copy_string_to_buffer(&self, data: String) { + let mut clipboard = arboard::Clipboard::new().unwrap(); + clipboard.set_text(data).unwrap(); + } + + fn get_string_from_buffer(&self) -> String { + let mut clipboard = arboard::Clipboard::new().unwrap(); + clipboard.get_text().unwrap_or("".to_string()) + } + + fn start_camera(&self) { + // Clear image. + { + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = None; + } + // Setup stop camera flag. + let stop_camera = self.stop_camera.clone(); + stop_camera.store(false, Ordering::Relaxed); + + Self::start_camera_capture( + self.cameras_amount.clone(), + self.camera_index.clone(), + stop_camera, + ); + } + + fn stop_camera(&self) { + // Stop camera. + self.stop_camera.store(true, Ordering::Relaxed); + } + + fn camera_image(&self) -> Option<(Vec, u32)> { + let r_image = LAST_CAMERA_IMAGE.read(); + if r_image.is_some() { + return r_image.clone(); + } + None + } + + fn can_switch_camera(&self) -> bool { + let amount = self.cameras_amount.load(Ordering::Relaxed); + amount > 1 + } + + fn switch_camera(&self) { + self.stop_camera(); + let index = self.camera_index.load(Ordering::Relaxed); + let amount = self.cameras_amount.load(Ordering::Relaxed); + if index == amount - 1 { + self.camera_index.store(0, Ordering::Relaxed); + } else { + self.camera_index.store(index + 1, Ordering::Relaxed); + } + self.start_camera(); + } + + fn share_data(&self, name: String, data: Vec) -> Result<(), std::io::Error> { + let folder = FileDialog::new() + .set_title(t!("share")) + .set_directory(dirs::home_dir().unwrap()) + .set_file_name(name.clone()) + .save_file(); + if let Some(folder) = folder { + let mut image = File::create(folder)?; + image.write_all(data.as_slice())?; + image.sync_all()?; + } + Ok(()) + } + + fn pick_file(&self) -> Option { + let file = FileDialog::new() + .set_title(t!("choose_file")) + .set_directory(dirs::home_dir().unwrap()) + .pick_file(); + if let Some(file) = file { + return Some(file.to_str().unwrap_or_default().to_string()); + } + None + } + + fn pick_image_file(&self) -> Option { + let file = FileDialog::new() + .set_title(t!("choose_file")) + .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) + .set_directory(dirs::home_dir().unwrap()) + .pick_file(); + file.and_then(|f| f.to_str().map(|s| s.to_string())) + } + + fn pick_folder(&self) -> Option { + let file = FileDialog::new() + .set_title(t!("choose_folder")) + .set_directory(dirs::home_dir().unwrap()) + .pick_folder(); + if let Some(file) = file { + return Some(file.to_str().unwrap_or_default().to_string()); + } + None + } + + fn picked_file(&self) -> Option { + None + } + + fn request_user_attention(&self) { + let r_ctx = self.ctx.read(); + if r_ctx.is_some() { + let ctx = r_ctx.as_ref().unwrap(); + // Request attention on taskbar. + ctx.send_viewport_cmd(ViewportCommand::RequestUserAttention( + UserAttentionType::Informational, + )); + // Un-minimize window. + if ctx.input(|i| i.viewport().minimized.unwrap_or(false)) { + ctx.send_viewport_cmd(ViewportCommand::Minimized(false)); + } + // Focus to window. + if !ctx.input(|i| i.viewport().focused.unwrap_or(false)) { + ctx.send_viewport_cmd(ViewportCommand::WindowLevel(WindowLevel::AlwaysOnTop)); + ctx.send_viewport_cmd(ViewportCommand::Focus); + } + ctx.request_repaint(); + } + self.attention_required.store(true, Ordering::Relaxed); + } + + fn user_attention_required(&self) -> bool { + self.attention_required.load(Ordering::Relaxed) + } + + fn clear_user_attention(&self) { + let r_ctx = self.ctx.read(); + if r_ctx.is_some() { + let ctx = r_ctx.as_ref().unwrap(); + ctx.send_viewport_cmd(ViewportCommand::RequestUserAttention( + UserAttentionType::Reset, + )); + ctx.send_viewport_cmd(ViewportCommand::WindowLevel(WindowLevel::Normal)); + } + self.attention_required.store(false, Ordering::Relaxed); + } +} + +lazy_static! { + /// Last captured image from started camera. + static ref LAST_CAMERA_IMAGE: Arc, u32)>>> = Arc::new(RwLock::new(None)); +} diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs new file mode 100644 index 00000000..33f5d946 --- /dev/null +++ b/src/gui/platform/mod.rs @@ -0,0 +1,71 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +pub use self::platform::*; + +#[cfg(target_os = "android")] +#[path = "android/mod.rs"] +pub mod platform; +#[cfg(not(target_os = "android"))] +#[path = "desktop/mod.rs"] +pub mod platform; + +pub trait PlatformCallbacks { + fn set_context(&mut self, ctx: &egui::Context); + fn exit(&self); + fn copy_string_to_buffer(&self, data: String); + fn get_string_from_buffer(&self) -> String; + fn start_camera(&self); + fn stop_camera(&self); + fn camera_image(&self) -> Option<(Vec, u32)>; + fn can_switch_camera(&self) -> bool; + fn switch_camera(&self); + fn share_data(&self, name: String, data: Vec) -> Result<(), std::io::Error>; + + /// Save bytes to a user-chosen location on the device (a "save as" dialog). + /// Desktop already does this via `share_data` (rfd save dialog); Android + /// overrides to use the Storage Access Framework (ACTION_CREATE_DOCUMENT) + /// instead of the share sheet. + fn save_file(&self, name: String, data: Vec) -> Result<(), std::io::Error> { + self.share_data(name, data) + } + /// Share plain text via the platform's native share sheet (e.g. a payment + /// link). Defaults to copying to the clipboard on platforms without a share + /// sheet (desktop). + fn share_text(&self, text: String) { + self.copy_string_to_buffer(text); + } + fn pick_file(&self) -> Option; + /// Native picker filtered to picture files; defaults to the plain picker + /// on platforms without filter support (magic-byte sniffing protects). + fn pick_image_file(&self) -> Option { + self.pick_file() + } + fn pick_folder(&self) -> Option; + fn picked_file(&self) -> Option; + fn request_user_attention(&self); + fn user_attention_required(&self) -> bool; + fn clear_user_attention(&self); + + /// Set the status-bar icon color to contrast the current theme. `white` = + /// light icons (for a dark background). No-op off Android. + fn set_status_bar_white_icons(&self, _white: bool) {} + + /// Play a short "error" haptic (e.g. a rejected over-balance payment). + /// No-op off Android. + fn vibrate_error(&self) {} + + /// Play a tiny "tick" haptic confirming a successful copy. No-op off Android. + fn vibrate_copy(&self) {} +} diff --git a/src/gui/theme.rs b/src/gui/theme.rs new file mode 100644 index 00000000..fa00a8da --- /dev/null +++ b/src/gui/theme.rs @@ -0,0 +1,363 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Goblin design tokens: three themes (light/dark/yellow) and density scales, +//! taken verbatim from the Goblin design handoff. + +use std::cell::Cell; + +use egui::Color32; + +use crate::AppConfig; + +/// Available color themes. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ThemeKind { + Light, + Dark, + Yellow, +} + +impl ThemeKind { + pub fn id(&self) -> &'static str { + match self { + ThemeKind::Light => "light", + ThemeKind::Dark => "dark", + ThemeKind::Yellow => "yellow", + } + } + + pub fn from_id(id: &str) -> Option { + match id { + "light" => Some(ThemeKind::Light), + "dark" => Some(ThemeKind::Dark), + "yellow" => Some(ThemeKind::Yellow), + _ => None, + } + } +} + +/// Color tokens for a theme. +pub struct ThemeTokens { + pub bg: Color32, + pub surface: Color32, + pub surface2: Color32, + pub text: Color32, + pub text_dim: Color32, + pub text_mute: Color32, + /// Text on surface/surface2 fills. Matches `text` in light/dark, but the + /// yellow theme has dark surfaces on a bright bg, so on-surface text must + /// be light there while `text` stays dark for the bg. + pub surface_text: Color32, + pub surface_text_dim: Color32, + pub surface_text_mute: Color32, + pub line: Color32, + pub accent: Color32, + pub accent_dark: Color32, + pub accent_ink: Color32, + pub pos: Color32, + pub neg: Color32, + pub chip: Color32, + pub hover: Color32, + /// Avatar background palette (initial ink picked by luminance). + pub avatar_pairs: [(Color32, Color32); 8], + /// Whether egui widgets should use the dark base style. + pub dark_base: bool, +} + +/// Avatar (background, ink) pairs shared by all themes — bright pastels +/// carry dark ink, saturated darks carry light ink. +const AVATAR_PAIRS: [(Color32, Color32); 8] = [ + ( + Color32::from_rgb(0xFF, 0xD6, 0x0A), + Color32::from_rgb(0x0E, 0x0E, 0x0C), + ), // accent yellow / ink + ( + Color32::from_rgb(0xFF, 0x8E, 0x3C), + Color32::from_rgb(0x26, 0x10, 0x02), + ), // orange / deep brown + ( + Color32::from_rgb(0x5B, 0xD2, 0x7A), + Color32::from_rgb(0x0E, 0x0E, 0x0C), + ), // light green / black + ( + Color32::from_rgb(0x7B, 0xA7, 0xFF), + Color32::from_rgb(0x0B, 0x14, 0x33), + ), // periwinkle / navy ink + ( + Color32::from_rgb(0x6B, 0x4F, 0xC8), + Color32::from_rgb(0xF4, 0xF0, 0xFF), + ), // purple / light text + ( + Color32::from_rgb(0xE1, 0x74, 0xD0), + Color32::from_rgb(0x32, 0x07, 0x2B), + ), // pink / dark plum + ( + Color32::from_rgb(0x1F, 0x7A, 0x5C), + Color32::from_rgb(0xE7, 0xFF, 0xF4), + ), // deep teal / light mint + ( + Color32::from_rgb(0xA0, 0xE6, 0x6E), + Color32::from_rgb(0x14, 0x22, 0x0A), + ), // lime / dark moss +]; + +pub const LIGHT: ThemeTokens = ThemeTokens { + bg: Color32::from_rgb(0xFA, 0xFA, 0xF7), + surface: Color32::from_rgb(0xFF, 0xFF, 0xFF), + surface2: Color32::from_rgb(0xF2, 0xF1, 0xEC), + text: Color32::from_rgb(0x0E, 0x0E, 0x0C), + text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63), + text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B), + surface_text: Color32::from_rgb(0x0E, 0x0E, 0x0C), + surface_text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63), + surface_text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B), + // rgba(14,14,12,0.08) premultiplied. + line: Color32::from_rgba_premultiplied(1, 1, 1, 20), + accent: Color32::from_rgb(0xFF, 0xD6, 0x0A), + accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00), + accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C), + pos: Color32::from_rgb(0x0E, 0x7C, 0x3A), + neg: Color32::from_rgb(0xB0, 0x48, 0x1E), + chip: Color32::from_rgb(0xF2, 0xF1, 0xEC), + hover: Color32::from_rgb(0xE9, 0xE7, 0xE0), + avatar_pairs: AVATAR_PAIRS, + dark_base: false, +}; + +pub const DARK: ThemeTokens = ThemeTokens { + bg: Color32::from_rgb(0x0E, 0x0E, 0x0C), + surface: Color32::from_rgb(0x1A, 0x1A, 0x17), + surface2: Color32::from_rgb(0x24, 0x24, 0x20), + text: Color32::from_rgb(0xFA, 0xFA, 0xF7), + text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), + text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), + surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7), + surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), + surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), + // rgba(255,255,255,0.08) premultiplied. + line: Color32::from_rgba_premultiplied(20, 20, 20, 20), + accent: Color32::from_rgb(0xFF, 0xD6, 0x0A), + accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00), + accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C), + pos: Color32::from_rgb(0x5B, 0xD2, 0x7A), + neg: Color32::from_rgb(0xFF, 0x8B, 0x5E), + chip: Color32::from_rgb(0x24, 0x24, 0x20), + hover: Color32::from_rgb(0x2E, 0x2E, 0x29), + avatar_pairs: AVATAR_PAIRS, + dark_base: true, +}; + +pub const YELLOW: ThemeTokens = ThemeTokens { + bg: Color32::from_rgb(0xFF, 0xD6, 0x0A), + surface: Color32::from_rgb(0x0E, 0x0E, 0x0C), + surface2: Color32::from_rgb(0x1A, 0x1A, 0x17), + text: Color32::from_rgb(0x0E, 0x0E, 0x0C), + text_dim: Color32::from_rgb(0x3A, 0x3A, 0x36), + // Muted on-bg tier darkened for the bright yellow bg: #6B6A63 was only + // 3.85:1 (sub-WCAG-AA); #55534A is 5.5:1 and still the faintest tier. + text_mute: Color32::from_rgb(0x55, 0x53, 0x4A), + surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7), + surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), + surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), + // rgba(14,14,12,0.18) premultiplied. + line: Color32::from_rgba_premultiplied(2, 2, 2, 46), + accent: Color32::from_rgb(0x0E, 0x0E, 0x0C), + accent_dark: Color32::from_rgb(0x24, 0x24, 0x20), + accent_ink: Color32::from_rgb(0xFF, 0xD6, 0x0A), + pos: Color32::from_rgb(0x0E, 0x7C, 0x3A), + neg: Color32::from_rgb(0x9E, 0x2E, 0x0E), + chip: Color32::from_rgba_premultiplied(2, 2, 2, 20), + hover: Color32::from_rgb(0xEF, 0xC8, 0x00), + avatar_pairs: AVATAR_PAIRS, + dark_base: false, +}; + +thread_local! { + /// Per-frame theme override (see [`scoped`]). egui renders on one thread, so + /// a thread-local Cell scopes a different theme to a single surface without + /// touching the persisted app config. + static OVERRIDE: Cell> = const { Cell::new(None) }; +} + +/// RAII guard that forces [`kind`]/[`tokens`] to a specific theme for its +/// lifetime, restoring the previous value on drop (panic-safe). Used to paint +/// one surface — the Pay tab — in the yellow theme regardless of the user's +/// chosen theme, à la a modern pay app's brand-colored pay screen. +#[must_use = "the override only lasts while the guard is alive"] +pub struct ScopedTheme(Option); + +impl Drop for ScopedTheme { + fn drop(&mut self) { + OVERRIDE.with(|c| c.set(self.0.take())); + } +} + +/// Override the active theme until the returned guard drops. +pub fn scoped(kind: ThemeKind) -> ScopedTheme { + ScopedTheme(OVERRIDE.with(|c| c.replace(Some(kind)))) +} + +/// Current theme kind: a scoped override if one is active, else app config +/// (dark is the product default). +pub fn kind() -> ThemeKind { + OVERRIDE.with(|c| c.get()).unwrap_or_else(AppConfig::theme) +} + +/// Current theme tokens. +pub fn tokens() -> &'static ThemeTokens { + match kind() { + ThemeKind::Light => &LIGHT, + ThemeKind::Dark => &DARK, + ThemeKind::Yellow => &YELLOW, + } +} + +/// Set each frame by the Pay surface (which paints a bright yellow top under a +/// possibly-dark global theme), so the status bar can pick readable icons for it. +static YELLOW_SURFACE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Flag whether the bright Pay/yellow surface is currently on screen. +pub fn set_status_surface_yellow(yellow: bool) { + YELLOW_SURFACE.store(yellow, std::sync::atomic::Ordering::Relaxed); +} + +/// Whether the status bar should use light (white) icons: true on the dark +/// theme (dark top), false on the light/yellow themes (bright top). The bright +/// Pay surface forces dark icons even when the global theme is dark. +pub fn status_bar_white_icons() -> bool { + if YELLOW_SURFACE.load(std::sync::atomic::Ordering::Relaxed) { + return false; + } + tokens().dark_base +} + +/// Density scales from the design handoff. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DensityKind { + Compact, + Regular, + Comfy, +} + +impl DensityKind { + pub fn id(&self) -> &'static str { + match self { + DensityKind::Compact => "compact", + DensityKind::Regular => "regular", + DensityKind::Comfy => "comfy", + } + } + + pub fn from_id(id: &str) -> Option { + match id { + "compact" => Some(DensityKind::Compact), + "regular" => Some(DensityKind::Regular), + "comfy" => Some(DensityKind::Comfy), + _ => None, + } + } +} + +/// Spacing tokens for a density. +#[derive(Clone, Copy)] +pub struct DensityTokens { + pub pad: f32, + pub gap: f32, + pub radius: f32, + pub row: f32, +} + +pub const COMPACT: DensityTokens = DensityTokens { + pad: 12.0, + gap: 10.0, + radius: 10.0, + row: 56.0, +}; +pub const REGULAR: DensityTokens = DensityTokens { + pad: 16.0, + gap: 14.0, + radius: 16.0, + row: 64.0, +}; +pub const COMFY: DensityTokens = DensityTokens { + pad: 20.0, + gap: 18.0, + radius: 22.0, + row: 72.0, +}; + +/// Current density tokens from app config (comfy is the product default). +pub fn density() -> DensityTokens { + match AppConfig::density() { + DensityKind::Compact => COMPACT, + DensityKind::Regular => REGULAR, + DensityKind::Comfy => COMFY, + } +} + +/// Font family helpers for the Geist weight stack registered in `setup_fonts`. +pub mod fonts { + use egui::{FontFamily, FontId}; + + pub fn regular() -> FontFamily { + FontFamily::Proportional + } + + pub fn medium() -> FontFamily { + FontFamily::Name("geist-medium".into()) + } + + pub fn semibold() -> FontFamily { + FontFamily::Name("geist-semibold".into()) + } + + pub fn bold() -> FontFamily { + FontFamily::Name("geist-bold".into()) + } + + pub fn mono() -> FontFamily { + FontFamily::Monospace + } + + pub fn mono_semibold() -> FontFamily { + FontFamily::Name("geist-mono-sb".into()) + } + + /// Uppercase kicker label size (11px in the design). + pub fn kicker() -> FontId { + FontId::new(11.0, semibold()) + } +} + +/// Pick a readable ink (black or white) for the given background by luminance. +pub fn ink_for(bg: Color32) -> Color32 { + let lum = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32; + if lum > 140.0 { + Color32::from_rgb(0x0E, 0x0E, 0x0C) + } else { + Color32::from_rgb(0xFA, 0xFA, 0xF7) + } +} + +/// Avatar (background, ink) pair for a hue index. +pub fn avatar_pair(hue: usize) -> (Color32, Color32) { + let pairs = &tokens().avatar_pairs; + pairs[hue % pairs.len()] +} + +/// Number of avatar color pairs (hue derivation modulus). +pub fn avatar_pairs_len() -> usize { + tokens().avatar_pairs.len() +} diff --git a/src/gui/views/camera.rs b/src/gui/views/camera.rs new file mode 100644 index 00000000..70879f8e --- /dev/null +++ b/src/gui/views/camera.rs @@ -0,0 +1,528 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::load::SizedTexture; +use egui::{Pos2, Rect, RichText, TextureOptions, UiBuilder, Widget}; +use grin_keychain::mnemonic::WORDS; +use grin_util::ZeroingString; +use grin_wallet_libwallet::SlatepackAddress; +use image::{DynamicImage, EncodableLayout}; +use parking_lot::RwLock; +use std::sync::Arc; +use std::thread; + +use crate::gui::Colors; +use crate::gui::icons::CAMERA_ROTATE; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::View; +use crate::gui::views::types::{QrScanResult, QrScanState}; +use crate::wallet::WalletUtils; +use crate::wallet::types::PhraseSize; + +/// Camera QR code scanner. +pub struct CameraContent { + /// QR code scanning progress and result. + qr_scan_state: Arc>, + /// Uniform Resources URIs collected from QR code scanning. + ur_data: Arc, usize)>>>, + /// When waiting for the first frame started, to surface missing cameras. + wait_start: std::time::Instant, +} + +impl Default for CameraContent { + fn default() -> Self { + Self { + qr_scan_state: Arc::new(RwLock::new(QrScanState::default())), + ur_data: Arc::new(RwLock::new(None)), + wait_start: std::time::Instant::now(), + } + } +} + +impl CameraContent { + /// Draw camera content. + pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let rect = if let Some(img_data) = cb.camera_image() { + if let Ok(img) = image::load_from_memory(&*img_data.0) { + // Process image to find QR code. + self.scan_qr(&img); + + // Draw image. + let img_rect = self.image_ui(ui, img, img_data.1); + + img_rect + } else { + self.loading_ui(ui) + } + } else { + self.loading_ui(ui) + }; + + // Show UR scan progress. + self.ur_progress_ui(ui, &rect); + + // Show button to switch cameras. + if cb.can_switch_camera() { + let r = { + let mut r = rect.clone(); + r.min.y = r.max.y - 52.0; + r.min.x = r.max.x - 52.0; + r + }; + ui.scope_builder(UiBuilder::new().max_rect(r), |ui| { + let rotate_img = CAMERA_ROTATE.to_string(); + View::button(ui, rotate_img, Colors::white_or_black(false), || { + cb.switch_camera(); + }); + }); + } + ui.add_space(6.0); + ui.ctx().request_repaint(); + } + + /// Draw camera image. + fn image_ui(&mut self, ui: &mut egui::Ui, mut img: DynamicImage, rotation: u32) -> Rect { + // Setup image rotation. + img = match rotation { + 90 => img.rotate90(), + 180 => img.rotate180(), + 270 => img.rotate270(), + _ => img, + }; + if View::is_desktop() { + img = img.fliph(); + } + // Convert to ColorImage. + let color_img = match &img { + DynamicImage::ImageRgb8(image) => egui::ColorImage::from_rgb( + [image.width() as usize, image.height() as usize], + image.as_bytes(), + ), + other => { + let image = other.to_rgba8(); + egui::ColorImage::from_rgba_unmultiplied( + [image.width() as usize, image.height() as usize], + image.as_bytes(), + ) + } + }; + // Create image texture. + let texture = + ui.ctx() + .load_texture("camera_image", color_img.clone(), TextureOptions::default()); + let img_size = egui::emath::vec2(color_img.width() as f32, color_img.height() as f32); + let sized_img = SizedTexture::new(texture.id(), img_size); + egui::Image::from_texture(sized_img) + // Setup to crop image at square. + .uv(Rect::from([ + if img_size.y > img_size.x { + Pos2::new(0.0, 1.0 - (img_size.x / img_size.y)) + } else { + Pos2::new(1.0 - (img_size.y / img_size.x), 0.0) + }, + Pos2::new(1.0, 1.0), + ])) + .max_height(ui.available_width()) + .maintain_aspect_ratio(false) + .shrink_to_fit() + .ui(ui) + .rect + } + + /// Draw animated QR code scanning progress. + fn ur_progress_ui(&self, ui: &mut egui::Ui, rect: &Rect) { + let show_ur_progress = { self.ur_data.as_ref().read().is_some() }; + if show_ur_progress { + ui.scope_builder(UiBuilder::new().max_rect(rect.clone()), |ui| { + ui.centered_and_justified(|ui| { + ui.label( + RichText::new(format!("{}%", self.ur_progress())) + .size(32.0) + .color(Colors::gold_dark()), + ); + }); + }); + } + } + + /// Draw camera loading progress content, or a missing-camera notice when + /// no frame ever arrives (no device, device busy, or capture failed). + fn loading_ui(&self, ui: &mut egui::Ui) -> Rect { + if self.wait_start.elapsed().as_secs() >= 5 { + let space = ui.available_width() / 3.0; + return ui + .vertical_centered(|ui| { + ui.add_space(space); + ui.label( + RichText::new("No camera found") + .size(17.0) + .color(Colors::inactive_text()), + ); + ui.add_space(space); + }) + .response + .rect; + } + let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0; + ui.vertical_centered(|ui| { + ui.add_space(space); + View::big_loading_spinner(ui); + ui.add_space(space); + }) + .response + .rect + } + + /// Check if image is processing to find QR code. + fn image_processing(&self) -> bool { + let r_scan = self.qr_scan_state.read(); + r_scan.image_processing + } + + /// Get UR scanning progress in percents. + fn ur_progress(&self) -> i32 { + // Setup data. + let r_data = self.ur_data.read(); + let (data, total) = r_data.clone().unwrap_or((vec![], 0)); + if data.is_empty() { + return 0; + } + // Calculate progress. + let mut complete = 0; + for i in &data { + if !i.is_empty() { + complete += 1; + } + } + (100 * complete / total) as i32 + } + + /// Parse QR code from provided image data. + fn scan_qr(&self, image_data: &DynamicImage) { + // Do not scan when another image is processing. + if self.image_processing() { + return; + } + // Setup scanning flag. + { + let mut w_scan = self.qr_scan_state.write(); + w_scan.image_processing = true; + } + + let image_data = image_data.clone(); + let qr_scan_state = self.qr_scan_state.clone(); + let ur_data = self.ur_data.clone(); + + let on_scan = async move { + // Prepare image data. + let img = image_data.to_luma8(); + let mut img: rqrr::PreparedImage = rqrr::PreparedImage::prepare(img); + // Scan and save results. + let grids = img.detect_grids(); + if let Some(g) = grids.get(0) { + let mut qr_data = vec![]; + if let Ok(_) = g.decode_to(&mut qr_data) { + // Setup scanned data into text. + let text = String::from_utf8(qr_data.clone()).unwrap_or("".to_string()); + // Setup current text. + let cur_text = { + let r_scan = qr_scan_state.read(); + let text = if let Some(res) = r_scan.qr_scan_result.clone() { + res.text() + } else { + "".to_string() + }; + text + }; + // Parse non-empty data if parsed text is different from saved. + if !qr_data.is_empty() && (cur_text.is_empty() || text != cur_text) { + let res = Self::parse_qr_code(qr_data); + match res { + QrScanResult::URPart(uri, index, total) => { + // Setup current UR data. + let mut cur_data = { + let r_data = ur_data.read(); + let mut cur_data = vec!["".to_string(); total]; + if let Some((d, _)) = r_data.clone() { + cur_data = d; + } + cur_data + }; + if !cur_data.contains(&uri) { + // Save part of UR data. + { + cur_data.insert(index, uri); + let mut w_data = ur_data.write(); + *w_data = Some((cur_data.clone(), total)); + } + // Setup UR decoder. + let mut decoder = ur::Decoder::default(); + for m in cur_data { + if !m.is_empty() { + if let Ok(_) = decoder.receive(m.as_str()) { + continue; + } else { + break; + } + } + } + // Check if UR data is complete. + if decoder.complete() { + if let Ok(data) = decoder.message() { + // Parse complete data. + let res = Self::parse_qr_code(data.unwrap_or(vec![])); + // Clean UR data. + let mut w_data = ur_data.write(); + *w_data = None; + // Save scan result. + let mut w_scan = qr_scan_state.write(); + w_scan.qr_scan_result = Some(res); + return; + } + } + } + } + _ => { + // Clean UR data. + let mut w_data = ur_data.write(); + *w_data = None; + // Save scan result. + let mut w_scan = qr_scan_state.write(); + w_scan.qr_scan_result = Some(res); + return; + } + } + } + } + } + // Reset scanning flag to process again. + { + let mut w_scan = qr_scan_state.write(); + w_scan.image_processing = false; + } + }; + + // Launch scanner at separate thread. + thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(on_scan); + }); + } + + /// Parse QR code scan result. + fn parse_qr_code(data: Vec) -> QrScanResult { + // Check if string starts with Grin address prefix. + let text_string = String::from_utf8(data.clone()).unwrap_or("".to_string()); + let text = text_string.trim(); + if text.starts_with("tgrin") || text.starts_with("grin") { + if SlatepackAddress::try_from(text).is_ok() { + return QrScanResult::Address(ZeroingString::from(text)); + } + } + + // Check if string contains Slatepack message prefix and postfix. + if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") { + return QrScanResult::Slatepack(text.to_string()); + } + + // Check Uniform Resource data. + // https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md + if text.starts_with("ur:bytes/") { + let split = text.split("/").collect::>(); + if let Some(index_total) = split.get(1) { + if let Some((index, total)) = index_total.split_once("-") { + let index = index.parse::(); + let total = total.parse::(); + if index.is_ok() && total.is_ok() { + let index = index.unwrap() - 1; + let total = total.unwrap(); + return QrScanResult::URPart(text_string, index, total); + } + } + } + } + + // Check Compact SeedQR format. + // https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#compactseedqr-specification + if data.len() <= 32 && 16 <= data.len() && data.len() % 4 == 0 { + // Setup words amount. + let total_bits = data.len() * 8; + let checksum_bits = total_bits / 32; + let total_words = (total_bits + checksum_bits) / 11; + // Setup entropy. + let mut entropy = data.clone(); + WalletUtils::setup_checksum(&mut entropy); + // Setup bits. + let mut bits = vec![false; entropy.len() * 8]; + for i in 0..entropy.len() { + for j in 0..8 { + bits[(i * 8) + j] = (entropy[i] & (1 << (7 - j))) != 0; + } + } + // Extract word index. + let extract_index = |i: usize| -> usize { + let mut index = 0; + for j in 0..11 { + index = index << 1; + if bits[(i * 11) + j] { + index += 1; + } + } + return index; + }; + // Setup words. + let mut words = "".to_string(); + for n in 0..total_words { + // Setup word index. + let index = extract_index(n); + // Setup word. + let empty_word = "".to_string(); + let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone(); + if word.is_empty() { + words = empty_word; + break; + } + words = if words.is_empty() { + format!("{}", word) + } else { + format!("{} {}", words, word) + }; + } + if !words.is_empty() { + return QrScanResult::SeedQR(ZeroingString::from(words)); + } + } + + // Check Standard SeedQR format. + // https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#standard-seedqr-specification + let only_numbers = || { + for c in text.chars() { + if !c.is_numeric() { + return false; + } + } + true + }; + if !text.is_empty() && data.len() <= 96 && data.len() % 4 == 0 && only_numbers() { + if let Some(_) = PhraseSize::type_for_value(text.len() / 4) { + let chars: Vec = text.trim().chars().collect(); + let split = &chars + .chunks(4) + .map(|chunk| { + chunk + .iter() + .collect::() + .trim() + .trim_start_matches("0") + .to_string() + }) + .collect::>(); + let mut words = "".to_string(); + for i in split { + let index = if i.is_empty() { + 0usize + } else { + i.parse::().unwrap_or(WORDS.len()) + }; + let empty_word = "".to_string(); + let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone(); + // Return text result when BIP39 word was not found. + if word.is_empty() { + return QrScanResult::Text(ZeroingString::from(text)); + } + words = if words.is_empty() { + format!("{}", word) + } else { + format!("{} {}", words, word) + }; + } + return QrScanResult::SeedQR(ZeroingString::from(words)); + } + } + + // Return default text result. + QrScanResult::Text(ZeroingString::from(text)) + } + + /// Get QR code scan result. + pub fn qr_scan_result(&self) -> Option { + let r_scan = self.qr_scan_state.read(); + if r_scan.qr_scan_result.is_some() { + return Some(r_scan.qr_scan_result.clone().unwrap()); + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Render a QR the way the goblin receive card paints it (dark modules + /// on a white plate with ~5% padding, goblin mark covering the center) + /// and prove the camera scanner pipeline (rqrr) decodes it. Guards both + /// the scan path and the card's scannability by third-party apps. + #[test] + fn goblin_receive_qr_decodes_with_center_mark() { + let uri = "nostr:npub15l60z00nm4ptmnsj9lcp4husnaltytw85eu05dt7ksdmsje0p98su2f0ch"; + let qr = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::High).unwrap(); + let n = qr.size(); + + // Mirror widgets::qr_code geometry at its receive-card size. + let size = 220.0f32; + let pad = (size * 0.05).max(8.0); + let dim = (size + pad * 2.0).ceil() as u32; + let cell = size / n as f32; + let mut img = image::GrayImage::from_pixel(dim, dim, image::Luma([255u8])); + let mut fill = |x0: f32, y0: f32, w: f32, h: f32, v: u8| { + for y in y0.max(0.0) as u32..((y0 + h).min(dim as f32) as u32) { + for x in x0.max(0.0) as u32..((x0 + w).min(dim as f32) as u32) { + img.put_pixel(x, y, image::Luma([v])); + } + } + }; + for y in 0..n { + for x in 0..n { + if qr.get_module(x, y) { + fill(pad + x as f32 * cell, pad + y as f32 * cell, cell, cell, 14); + } + } + } + // The goblin mark backing square over the center modules. + let backing = size * 0.19; + let c = dim as f32 / 2.0; + fill(c - backing / 2.0, c - backing / 2.0, backing, backing, 255); + + let mut prepared = rqrr::PreparedImage::prepare(img); + let grids = prepared.detect_grids(); + assert_eq!(grids.len(), 1, "scanner should find exactly one QR"); + let mut data = vec![]; + grids[0].decode_to(&mut data).expect("QR should decode"); + assert_eq!(String::from_utf8(data).unwrap(), uri); + } + + /// A scanned nostr URI must come back as plain text (the send flow + /// strips the scheme and resolves the npub), never as another variant. + #[test] + fn nostr_uri_parses_as_text() { + let uri = "nostr:npub15l60z00nm4ptmnsj9lcp4husnaltytw85eu05dt7ksdmsje0p98su2f0ch"; + match CameraContent::parse_qr_code(uri.as_bytes().to_vec()) { + QrScanResult::Text(text) => assert_eq!(text.to_string(), uri), + other => panic!("expected Text, got {:?}", other.text()), + } + } +} diff --git a/src/gui/views/content.rs b/src/gui/views/content.rs new file mode 100644 index 00000000..75fe2c3c --- /dev/null +++ b/src/gui/views/content.rs @@ -0,0 +1,370 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::RichText; +use egui::os::OperatingSystem; +use lazy_static::lazy_static; +use std::fs; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::gui::Colors; +use crate::gui::icons::FILE_X; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::NetworkContent; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::wallets::WalletsContent; +use crate::gui::views::{Modal, View}; +use crate::node::Node; +use crate::{AppConfig, Settings}; + +lazy_static! { + /// Global state to check if [`NetworkContent`] panel is open. + static ref NETWORK_PANEL_OPEN: AtomicBool = AtomicBool::new(false); +} + +/// Contains main ui content, handles side panel state. +pub struct Content { + /// Side panel [`NetworkContent`] content. + network: NetworkContent, + + /// Central panel [`WalletsContent`] content. + wallets: WalletsContent, + + /// Check if app exit is allowed on Desktop close event. + pub exit_allowed: bool, + /// Flag to show exit progress at [`Modal`]. + show_exit_progress: bool, + + /// Flag to check it's first draw of content. + first_draw: bool, +} + +impl Default for Content { + fn default() -> Self { + // Exit from eframe only for non-mobile platforms. + let os = OperatingSystem::from_target_os(); + let exit_allowed = os == OperatingSystem::Android || os == OperatingSystem::IOS; + Self { + network: NetworkContent::default(), + wallets: WalletsContent::default(), + exit_allowed, + show_exit_progress: false, + first_draw: true, + } + } +} + +/// Identifier for integrated node warning [`Modal`] on Android. +const ANDROID_INTEGRATED_NODE_WARNING_MODAL: &'static str = "android_node_warning_modal"; +/// Identifier for crash report [`Modal`]. +const CRASH_REPORT_MODAL: &'static str = "crash_report_modal"; + +impl ContentContainer for Content { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + Self::EXIT_CONFIRMATION_MODAL, + ANDROID_INTEGRATED_NODE_WARNING_MODAL, + CRASH_REPORT_MODAL, + ] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + Self::EXIT_CONFIRMATION_MODAL => self.exit_modal_content(ui, modal, cb), + ANDROID_INTEGRATED_NODE_WARNING_MODAL => self.android_warning_modal_ui(ui), + CRASH_REPORT_MODAL => self.crash_report_modal_ui(ui, cb), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let dual_panel = Self::is_dual_panel_mode(ui.ctx()); + let (is_panel_open, mut panel_width) = network_panel_state_width(ui.ctx(), dual_panel); + if self.network.showing_settings() { + panel_width = ui.available_width(); + } + // The open-wallet (Goblin) surface is full-bleed: node info lives in + // its sidebar, so the network column stays hidden while it shows. + // Same for first-run onboarding, which owns the whole window. + let wallet_open = self.wallets.showing_wallet() || self.wallets.onboarding_active(); + // On the returning-user wallet list the node is demoted to a chip: + // the panel opens only when explicitly toggled, never forced open by + // dual-panel mode (otherwise GRIM's node column dominates the list). + let list_screen = self.wallets.wallet_list_screen(); + // The app-settings (cog) screen owns the node now: it lives in the + // cog's own section, and the full panel opens only when explicitly + // requested from there — never auto-docked beside settings, which + // would expose the node twice on a wide screen. + let app_settings = self.wallets.showing_settings(); + let show_network = !wallet_open + && if list_screen || app_settings { + Self::is_network_panel_open() + } else { + is_panel_open + }; + + // Show network content. + egui::SidePanel::left("network_panel") + .resizable(false) + .exact_width(panel_width) + .frame(egui::Frame { + ..Default::default() + }) + .show_animated_inside(ui, show_network, |ui| { + self.network.ui(ui, cb); + }); + + // Show wallets content. + egui::CentralPanel::default() + .frame(egui::Frame { + ..Default::default() + }) + .show_inside(ui, |ui| { + self.wallets.ui(ui, cb); + }); + + if self.first_draw { + // Show crash report or integrated node Android warning. + if Settings::crash_check_path().exists() { + Modal::new(CRASH_REPORT_MODAL) + .closeable(false) + .position(ModalPosition::Center) + .title(t!("crash_report")) + .show(); + } else if OperatingSystem::from_target_os() == OperatingSystem::Android + && AppConfig::android_integrated_node_warning_needed() + { + Modal::new(ANDROID_INTEGRATED_NODE_WARNING_MODAL) + .title(t!("network.node")) + .show(); + } + self.first_draw = false; + } + } +} + +impl Content { + /// Default width of side panel at application UI. + pub const SIDE_PANEL_WIDTH: f32 = 400.0; + /// Desktop window title height. + pub const WINDOW_TITLE_HEIGHT: f32 = 38.0; + /// Margin of window frame at desktop. + pub const WINDOW_FRAME_MARGIN: f32 = 6.0; + + /// Identifier for exit confirmation [`Modal`]. + pub const EXIT_CONFIRMATION_MODAL: &'static str = "exit_confirmation_modal"; + + /// Called to navigate back, return `true` if action was not consumed. + pub fn on_back(&mut self, ctx: &egui::Context, cb: &dyn PlatformCallbacks) -> bool { + if Modal::on_back() { + let dual_panel = Self::is_dual_panel_mode(ctx); + if !dual_panel && Self::is_network_panel_open() { + if self.network.on_back() { + Self::toggle_network_panel(); + return false; + } + } else if self.wallets.on_back(cb) { + Self::show_exit_modal(); + return false; + } + } + true + } + + /// Check if ui can show [`NetworkContent`] and [`WalletsContent`] at same time. + pub fn is_dual_panel_mode(ctx: &egui::Context) -> bool { + let (w, h) = View::window_size(ctx); + // Screen is wide if width is greater than height or just 20% smaller. + let is_wide_screen = w > h || w + (w * 0.2) >= h; + // Dual panel mode is available when window is wide and its width is at least 2 times + // greater than minimal width of the side panel plus display insets from both sides. + let side_insets = View::get_left_inset() + View::get_right_inset(); + is_wide_screen && w >= (Self::SIDE_PANEL_WIDTH * 2.0) + side_insets + } + + /// Toggle [`NetworkContent`] panel state. + pub fn toggle_network_panel() { + let is_open = NETWORK_PANEL_OPEN.load(Ordering::Relaxed); + NETWORK_PANEL_OPEN.store(!is_open, Ordering::Relaxed); + } + + /// Check if [`NetworkContent`] panel is open. + pub fn is_network_panel_open() -> bool { + NETWORK_PANEL_OPEN.load(Ordering::Relaxed) + } + + /// Show exit confirmation [`Modal`]. + pub fn show_exit_modal() { + Modal::new(Self::EXIT_CONFIRMATION_MODAL) + .title(t!("confirmation")) + .show(); + } + + /// Draw exit confirmation modal content. + fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + if self.show_exit_progress { + if !Node::is_running() && !Node::data_dir_changing() { + self.exit_allowed = true; + cb.exit(); + Modal::close(); + } + ui.add_space(16.0); + ui.vertical_centered(|ui| { + View::small_loading_spinner(ui); + ui.add_space(12.0); + let exit_status_text = if Node::data_dir_changing() { + t!("moving_files") + } else { + t!("sync_status.shutdown") + }; + ui.label( + RichText::new(exit_status_text) + .size(17.0) + .color(Colors::text(false)), + ); + }); + ui.add_space(10.0); + } else { + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("modal_exit.description")) + .size(17.0) + .color(Colors::text(false)), + ); + }); + ui.add_space(10.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button_ui( + ui, + t!("modal_exit.exit"), + Colors::white_or_black(false), + |_| { + if !Node::is_running() && !Node::data_dir_changing() { + self.exit_allowed = true; + cb.exit(); + Modal::close(); + } else { + Node::stop(true); + modal.disable_closing(); + Modal::set_title(t!("modal_exit.exit")); + self.show_exit_progress = true; + } + }, + ); + }); + }); + ui.add_space(6.0); + } + } + + /// Draw content for integrated node warning [`Modal`] on Android. + fn android_warning_modal_ui(&mut self, ui: &mut egui::Ui) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network.android_warning")) + .size(16.0) + .color(Colors::text(false)), + ); + }); + ui.add_space(8.0); + ui.vertical_centered_justified(|ui| { + View::button(ui, t!("continue"), Colors::white_or_black(false), || { + AppConfig::show_android_integrated_node_warning(); + Modal::close(); + }); + }); + ui.add_space(6.0); + } + + /// Draw content for integrated node warning [`Modal`] on Android. + fn crash_report_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("crash_report_warning")) + .size(16.0) + .color(Colors::text(false)), + ); + ui.add_space(6.0); + // Draw button to share log file. + let text = format!("{} {}", FILE_X, t!("share")); + View::colored_text_button( + ui, + text, + Colors::blue(), + Colors::white_or_black(false), + || { + if let Ok(data) = fs::read_to_string(Settings::log_path()) { + let name = Settings::LOG_FILE_NAME.to_string(); + let _ = cb.share_data(name, data.as_bytes().to_vec()); + } + Settings::delete_crash_check(); + Modal::close(); + }, + ); + }); + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Settings::delete_crash_check(); + Modal::close(); + }, + ); + }); + ui.add_space(6.0); + } +} + +/// Get [`NetworkContent`] panel state and width. +fn network_panel_state_width(ctx: &egui::Context, dual_panel: bool) -> (bool, f32) { + let is_panel_open = dual_panel || Content::is_network_panel_open(); + let panel_width = if dual_panel { + Content::SIDE_PANEL_WIDTH + View::get_left_inset() + } else { + let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); + View::window_size(ctx).0 + - if View::is_desktop() + && !is_fullscreen + && OperatingSystem::from_target_os() != OperatingSystem::Mac + { + Content::WINDOW_FRAME_MARGIN * 2.0 + } else { + 0.0 + } + }; + (is_panel_open, panel_width) +} diff --git a/src/gui/views/file_pick.rs b/src/gui/views/file_pick.rs new file mode 100644 index 00000000..7cff1289 --- /dev/null +++ b/src/gui/views/file_pick.rs @@ -0,0 +1,195 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::CornerRadius; +use parking_lot::RwLock; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{fs, thread}; + +use crate::gui::Colors; +use crate::gui::icons::ARCHIVE_BOX; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::View; + +/// Type of button. +pub enum FilePickContentType { + Button(String), + ItemButton(CornerRadius), + Tab, +} + +/// Button to pick file and parse its data into text. +pub struct FilePickContent { + /// Content type. + content_type: FilePickContentType, + + /// Flag to check if button is active. + active: bool, + + /// Flag to check if file is picking. + file_picking: Arc, + + /// Flag to check if folder should be picked. + pick_folder: bool, + /// Flag to parse file content after pick. + parse_file: bool, + /// Flag to check if file is parsing. + file_parsing: Arc, + /// File parsing result. + file_parsing_result: Arc>>, +} + +impl FilePickContent { + /// Create new content from provided type. + pub fn new(content_type: FilePickContentType) -> Self { + Self { + content_type, + active: false, + file_picking: Arc::new(AtomicBool::new(false)), + pick_folder: false, + parse_file: true, + file_parsing: Arc::new(AtomicBool::new(false)), + file_parsing_result: Arc::new(RwLock::new(None)), + } + } + + /// Pick folder. + pub fn pick_folder(mut self) -> Self { + self.pick_folder = true; + self + } + + /// Do not parse file content. + pub fn no_parse(mut self) -> Self { + self.parse_file = false; + self + } + + /// Enable or disable the button. + pub fn set_active(&mut self, active: bool) { + self.active = active; + } + + /// Draw content with provided callback to return path of the file. + pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks, pick: impl FnOnce(String)) { + if self.file_picking.load(Ordering::Relaxed) { + View::small_loading_spinner(ui); + // Check file pick result. + if let Some(path) = cb.picked_file() { + self.file_picking.store(false, Ordering::Relaxed); + if !path.is_empty() { + if self.parse_file { + self.parse_file(path); + } else { + pick(path); + } + } + } + } else if self.file_parsing.load(Ordering::Relaxed) { + View::small_loading_spinner(ui); + // Check file parsing result. + let has_result = { + let r_res = self.file_parsing_result.read(); + r_res.is_some() + }; + if has_result { + let text = { + let r_res = self.file_parsing_result.read(); + r_res.clone().unwrap() + }; + // Callback on result. + pick(text); + // Clear result. + let mut w_res = self.file_parsing_result.write(); + *w_res = None; + self.file_parsing.store(false, Ordering::Relaxed); + } + } else { + // Draw button to pick file. + match &self.content_type { + FilePickContentType::Button(text) => { + let text = format!("{} {}", ARCHIVE_BOX, text); + let text_color = Colors::blue(); + let fill = Colors::white_or_black(false); + View::colored_text_button(ui, text, text_color, fill, || { + self.on_file_pick(pick, cb); + }); + } + FilePickContentType::ItemButton(r) => { + View::item_button(ui, r.clone(), ARCHIVE_BOX, Some(Colors::blue()), || { + self.on_file_pick(pick, cb); + }); + } + FilePickContentType::Tab => { + let active = match self.active { + true => Some( + self.file_parsing.load(Ordering::Relaxed) + || self.file_picking.load(Ordering::Relaxed), + ), + false => None, + }; + View::tab_button(ui, ARCHIVE_BOX, Some(Colors::blue()), active, |_| { + self.on_file_pick(pick, cb); + }); + } + } + } + } + + /// Handle pick file request. + fn on_file_pick(&self, on_pick: impl FnOnce(String), cb: &dyn PlatformCallbacks) { + let path = if self.pick_folder { + cb.pick_folder() + } else { + cb.pick_file() + }; + if path.is_none() { + return; + } + let path = path.unwrap(); + // Wait for asynchronous file pick result if path is empty. + if path.is_empty() { + self.file_picking.store(true, Ordering::Relaxed); + return; + } + // Parse result if needed. + if self.parse_file { + self.parse_file(path); + } else { + on_pick(path); + } + } + + /// Handle picked file path. + fn parse_file(&self, path: String) { + self.file_parsing.store(true, Ordering::Relaxed); + let result = self.file_parsing_result.clone(); + thread::spawn(move || { + if path.ends_with(".gif") { + //TODO: Detect QR codes on GIF file. + } else if path.ends_with(".jpeg") || path.ends_with(".jpg") || path.ends_with(".png") { + //TODO: Detect QR codes on image files. + } else { + // Parse file as plain text. + let mut w_res = result.write(); + if let Ok(text) = fs::read_to_string(path) { + *w_res = Some(text); + } else { + *w_res = Some("".to_string()); + } + } + }); + } +} diff --git a/src/gui/views/goblin/avatars.rs b/src/gui/views/goblin/avatars.rs new file mode 100644 index 00000000..cefdd860 --- /dev/null +++ b/src/gui/views/goblin/avatars.rs @@ -0,0 +1,186 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Texture layer over the avatar disk cache: hands the UI ready +//! [`egui::TextureHandle`]s for usernames, fetching stale entries from the +//! NIP-05 server on background threads. Textures are only created on the UI +//! thread; workers send raw PNG bytes back over a channel. + +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use crate::nostr::avatar::AvatarCache; +use crate::nostr::nip05; +use crate::settings::Settings; + +/// Worker outcome for one name's avatar probe. +enum Fetched { + /// A custom avatar (content hash, png bytes). + Found(String, Vec), + /// The server confirmed the name has no avatar. + Absent, + /// The probe failed (network) — do NOT cache; retry later. + Failed, +} +type FetchResult = (String, Fetched); + +pub struct AvatarTextures { + cache: AvatarCache, + /// Ready textures; `None` records a known letter-fallback (no avatar). + textures: HashMap>, + inflight: HashSet, + tx: Sender, + rx: Receiver, +} + +impl Default for AvatarTextures { + fn default() -> Self { + let (tx, rx) = channel(); + Self { + cache: AvatarCache::new(Settings::base_path(Some("cache/avatars".to_string()))), + textures: HashMap::new(), + inflight: HashSet::new(), + tx, + rx, + } + } +} + +fn decode(png: &[u8]) -> Option { + // Server-fed bytes: decode under explicit limits so a hostile or breached + // avatar host can't blow up memory on the texture path. `fetch_avatar` + // only checks ≤1 MiB + PNG magic, not the decoded dimensions. + let mut reader = image::ImageReader::new(std::io::Cursor::new(png)); + reader.set_format(image::ImageFormat::Png); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(1024); + limits.max_image_height = Some(1024); + limits.max_alloc = Some(8 * 1024 * 1024); + reader.limits(limits); + let img = reader.decode().ok()?.to_rgba8(); + Some(egui::ColorImage::from_rgba_unmultiplied( + [img.width() as usize, img.height() as usize], + img.as_raw(), + )) +} + +impl AvatarTextures { + /// Texture for a bare username (no `@`), if it has a custom avatar. + /// Triggers a background refresh when the cache entry is stale. + pub fn texture_for( + &mut self, + ctx: &egui::Context, + server: &str, + name: &str, + ) -> Option { + self.drain(ctx); + let name = name.trim_start_matches('@').to_lowercase(); + if name.is_empty() { + return None; + } + if let Some(t) = self.textures.get(&name).cloned() { + // A known state (texture or confirmed-absent); refresh if stale. + if self.cache.stale(&name) { + self.spawn_fetch(server, &name); + } + return t; + } + // Disk cache hit → texture now, refresh in background if stale. + if let Some((_, bytes)) = self.cache.cached(&name) { + let tex = decode(&bytes) + .map(|img| ctx.load_texture(format!("avatar_{name}"), img, Default::default())); + self.textures.insert(name.clone(), tex.clone()); + if self.cache.stale(&name) { + self.spawn_fetch(server, &name); + } + return tex; + } + if self.cache.stale(&name) { + self.spawn_fetch(server, &name); + } else { + // Fresh negative entry: letter fallback without re-probing. + self.textures.insert(name.clone(), None); + } + None + } + + /// Install the just-uploaded avatar without waiting for a round-trip. + pub fn set_own(&mut self, ctx: &egui::Context, name: &str, hash: &str, png: &[u8]) { + let name = name.trim_start_matches('@').to_lowercase(); + self.cache.store(&name, hash, png); + let tex = decode(png) + .map(|img| ctx.load_texture(format!("avatar_{name}"), img, Default::default())); + self.textures.insert(name, tex); + } + + /// Forget a name (released or rotated away). + pub fn invalidate(&mut self, name: &str) { + let name = name.trim_start_matches('@').to_lowercase(); + self.cache.remove(&name); + self.textures.remove(&name); + } + + fn drain(&mut self, ctx: &egui::Context) { + while let Ok((name, fetched)) = self.rx.try_recv() { + self.inflight.remove(&name); + match fetched { + Fetched::Found(hash, png) => { + self.cache.store(&name, &hash, &png); + let tex = decode(&png).map(|img| { + ctx.load_texture(format!("avatar_{name}"), img, Default::default()) + }); + self.textures.insert(name, tex); + } + Fetched::Absent => { + self.cache.mark_absent(&name); + self.textures.insert(name, None); + } + // Network failure: leave the entry stale so the next frame + // retries. Never cache it as a confirmed "no avatar". + Fetched::Failed => {} + } + ctx.request_repaint(); + } + } + + fn spawn_fetch(&mut self, server: &str, name: &str) { + if self.inflight.contains(name) { + return; + } + self.inflight.insert(name.to_string()); + let tx = self.tx.clone(); + let server = server.to_string(); + let name = name.to_string(); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return, + }; + let fetched = rt.block_on(async { + match nip05::fetch_profile(&server, &name).await { + Some(Some(hash)) => match nip05::fetch_avatar(&server, &hash).await { + Some(png) => Fetched::Found(hash, png), + None => Fetched::Failed, + }, + Some(None) => Fetched::Absent, + None => Fetched::Failed, + } + }); + let _ = tx.send((name, fetched)); + }); + } +} diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs new file mode 100644 index 00000000..a593c9ae --- /dev/null +++ b/src/gui/views/goblin/data.rs @@ -0,0 +1,390 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Activity model: wallet transactions joined with nostr metadata. + +use grin_wallet_libwallet::TxLogEntryType; + +use crate::nostr::{Contact, NostrSendStatus, NostrStore, TxNostrMeta}; +use crate::wallet::Wallet; +use crate::wallet::types::WalletTx; + +/// A unified activity entry for the Goblin feed. +pub struct ActivityItem { + pub tx_id: u32, + pub title: String, + pub note: Option, + pub amount: u64, + pub incoming: bool, + pub confirmed: bool, + /// Canceled/expired before completing (wallet-cancelled tx or expired meta). + pub canceled: bool, + pub system: bool, + pub hue: usize, + pub time: i64, + /// Counterparty npub hex, when known. + pub npub: Option, +} + +/// Full detail for the receipt / transaction-detail screen: GRIM tx data +/// joined with the nostr counterparty + note. Mimblewimble keeps the chain +/// private, but this is a LOCAL archive (like GRIM), so we surface whatever +/// the wallet recorded plus the npub/username we exchanged with. +pub struct ReceiptDetail { + pub tx_id: u32, + pub title: String, + pub hue: usize, + pub npub: Option, + pub amount: u64, + pub incoming: bool, + pub confirmed: bool, + /// Canceled/expired before completing. + pub canceled: bool, + /// Whether the counterparty has a real identity (petname / verified NIP-05) + /// rather than just a bare npub. Gates the redundant To/From name rows. + pub has_identity: bool, + /// (current confirmations, required) when still pending and computable. + pub confs: Option<(u64, u64)>, + pub time: i64, + pub note: Option, + /// Network fee in atomic units (sends only; unknown for receives). + pub fee: Option, + pub slate_id: Option, +} + +/// Build the receipt detail for a transaction id. +pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option { + let data = wallet.get_data()?; + let txs = data.txs.as_ref()?; + let tx = txs.iter().find(|t| t.data.id == tx_id)?; + let incoming = matches!( + tx.data.tx_type, + TxLogEntryType::TxReceived | TxLogEntryType::ConfirmedCoinbase + ); + let system = matches!(tx.data.tx_type, TxLogEntryType::ConfirmedCoinbase); + let slate_id = tx.data.tx_slate_id.map(|u| u.to_string()); + let store = wallet.nostr_service().map(|s| s.store.clone()); + let store_ref = store.as_deref(); + let meta: Option = slate_id + .as_ref() + .and_then(|sid| store_ref.and_then(|s| s.tx_meta(sid))); + let (title, hue) = if system { + ("Mining reward".to_string(), 5) + } else if let Some(m) = &meta { + store_ref + .map(|s| contact_title(s, &m.npub)) + .unwrap_or_else(|| (short_npub(&m.npub), 0)) + } else { + let label = if incoming { "Received" } else { "Sent" }; + ( + label.to_string(), + (tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(), + ) + }; + let note = meta.as_ref().and_then(|m| m.note.clone()); + let time = tx + .data + .confirmation_ts + .or(Some(tx.data.creation_ts)) + .map(|t| t.timestamp()) + .unwrap_or(0); + // The actual network fee from the tx kernel; a receive doesn't pay one. + let fee = if incoming { + None + } else { + Some(tx.data.fee.map(|f| f.fee()).unwrap_or(0)) + }; + // Confirmation progress toward the spendable threshold (min_confirmations). + // grin flips `confirmed` to true at the FIRST on-chain block, but a payment + // isn't spendable until min_confirmations — so keep counting 1/10 … 10/10 + // instead of jumping straight to "complete" at one block (which is why the + // count never appeared to move). + let min_conf = data.info.minimum_confirmations; + let confs = match tx.height { + Some(h) if h > 0 && data.info.last_confirmed_height >= h => { + let count = data.info.last_confirmed_height - h + 1; + if count >= min_conf { + None // matured — fully spendable + } else { + Some((count, min_conf)) + } + } + // On-chain but exact height not yet known: at least one block in. + _ if tx.data.confirmed => Some((1.min(min_conf), min_conf)), + // Broadcast but not yet mined. + _ => Some((0, min_conf)), + }; + let canceled = is_canceled(tx, meta.as_ref()); + let has_identity = meta + .as_ref() + .and_then(|m| store_ref.map(|s| has_real_identity(s, &m.npub))) + .unwrap_or(false); + Some(ReceiptDetail { + tx_id, + title, + hue, + npub: meta.map(|m| m.npub), + amount: tx.amount, + incoming, + confirmed: tx.data.confirmed, + canceled, + has_identity, + confs, + time, + note, + fee, + slate_id, + }) +} + +/// Activity entries exchanged with a single counterparty (for their profile). +pub fn history_with(wallet: &Wallet, npub: &str) -> Vec { + activity_items(wallet) + .into_iter() + .filter(|i| i.npub.as_deref() == Some(npub)) + .collect() +} + +/// True when a counterparty has a real, human identity (a local petname or a +/// verified NIP-05) rather than just a bare npub. Used to suppress the +/// redundant To/From name rows on the receipt when the name would just be the +/// same truncated npub shown in the "nostr" row. +pub fn has_real_identity(store: &NostrStore, npub: &str) -> bool { + store + .contact(npub) + .map(|c| { + c.petname.as_deref().map(|p| !p.is_empty()).unwrap_or(false) + || c.nip05_verified_at.is_some() + }) + .unwrap_or(false) +} + +/// Whether a transaction was canceled/expired before completing: a wallet-level +/// cancel (GRIM `TxSentCancelled`/`TxReceivedCancelled`), or expired nostr +/// metadata while still unconfirmed (a late on-chain confirmation still wins). +fn is_canceled(tx: &WalletTx, meta: Option<&TxNostrMeta>) -> bool { + matches!( + tx.data.tx_type, + TxLogEntryType::TxSentCancelled | TxLogEntryType::TxReceivedCancelled + ) || (!tx.data.confirmed + && meta + .map(|m| m.status == NostrSendStatus::Cancelled) + .unwrap_or(false)) +} + +/// Resolve the display title for a contact npub. +pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) { + if let Some(contact) = store.contact(npub) { + (display_name(&contact), contact.hue as usize) + } else { + let hue = hue_of(&npub); + (short_npub(npub), hue) + } +} + +/// Display rule: petname → bare name (verified, home authority) → `name · domain` +/// (verified, foreign authority — never bare, so a foreign "alice" can't pose as +/// your home "alice") → short npub. We never show the `@`. +pub fn display_name(contact: &Contact) -> String { + if let Some(petname) = &contact.petname { + if !petname.is_empty() { + return petname.clone(); + } + } + if let (Some(nip05), Some(_)) = (&contact.nip05, contact.nip05_verified_at) { + if let Some((name, domain)) = nip05.split_once('@') { + if domain == crate::nostr::nip05::home_domain() { + return name.to_string(); + } + // Foreign authority: show the domain (no @) so it can't masquerade + // as a home name. + return format!("{name} · {domain}"); + } + } + short_npub(&contact.npub) +} + +/// Whether this contact's name is verified against a name authority (gets the +/// little check), and the foreign domain to surface (None when it's the home +/// authority, where the domain is implied). +pub fn name_verification(contact: &Contact) -> Option> { + let nip05 = contact.nip05.as_ref()?; + contact.nip05_verified_at?; + let (_, domain) = nip05.split_once('@')?; + if domain == crate::nostr::nip05::home_domain() { + Some(None) + } else { + Some(Some(domain.to_string())) + } +} + +/// Short npub display (npub1abcd…wxyz) from a hex pubkey. +/// Avatar hue index derived from a hex pubkey (stable per identity, spread +/// across the full color-pair palette). +pub fn hue_of(hex: &str) -> usize { + usize::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0) + % crate::gui::theme::avatar_pairs_len() +} + +/// Single-line display form of a handle for narrow chips: middle-ellipsis +/// past 16 chars, keeping the tail (names often differ at the end). +pub fn short_handle(handle: &str) -> String { + let chars: Vec = handle.chars().collect(); + if chars.len() <= 16 { + return handle.to_string(); + } + let head: String = chars[..10].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{head}…{tail}") +} + +pub fn short_npub(hex: &str) -> String { + use nostr_sdk::{PublicKey, ToBech32}; + if let Ok(pk) = PublicKey::from_hex(hex) { + if let Ok(npub) = pk.to_bech32() { + // Standard truncation: "npub1" + 7 head chars … 6 tail chars. + if npub.len() > 18 { + return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]); + } + return npub; + } + } + format!("{}…", &hex[..8.min(hex.len())]) +} + +/// Full bech32 npub (no truncation), for the recipient picker's grey subtitle +/// where showing the complete key is more useful than repeating the truncation. +pub fn full_npub(hex: &str) -> String { + use nostr_sdk::{PublicKey, ToBech32}; + PublicKey::from_hex(hex) + .ok() + .and_then(|pk| pk.to_bech32().ok()) + .unwrap_or_else(|| hex.to_string()) +} + +/// Build the activity feed for a wallet, newest first. +pub fn activity_items(wallet: &Wallet) -> Vec { + let data = match wallet.get_data() { + Some(d) => d, + None => return vec![], + }; + let txs = data.txs.unwrap_or_default(); + let store = wallet.nostr_service().map(|s| s.store.clone()); + let mut items: Vec = txs + .iter() + .map(|tx| build_item(tx, store.as_deref())) + .collect(); + items.sort_by_key(|i| std::cmp::Reverse(i.time)); + items +} + +fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem { + let incoming = matches!( + tx.data.tx_type, + TxLogEntryType::TxReceived | TxLogEntryType::ConfirmedCoinbase + ); + let system = matches!(tx.data.tx_type, TxLogEntryType::ConfirmedCoinbase); + let slate_id = tx.data.tx_slate_id.map(|u| u.to_string()); + let meta: Option = slate_id + .as_ref() + .and_then(|sid| store.and_then(|s| s.tx_meta(sid))); + + let (title, hue) = if system { + ("Mining reward".to_string(), 5) + } else if let Some(meta) = &meta { + store + .map(|s| contact_title(s, &meta.npub)) + .unwrap_or_else(|| (short_npub(&meta.npub), 0)) + } else { + // Fall back to slatepack address counterparty or generic label. + let label = if incoming { + "Received".to_string() + } else { + "Sent".to_string() + }; + ( + label, + (tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(), + ) + }; + + let note = meta.as_ref().and_then(|m| m.note.clone()); + let time = tx + .data + .confirmation_ts + .or(Some(tx.data.creation_ts)) + .map(|t| t.timestamp()) + .unwrap_or(0); + let canceled = is_canceled(tx, meta.as_ref()); + + ActivityItem { + tx_id: tx.data.id, + title, + note, + amount: tx.amount, + incoming, + confirmed: tx.data.confirmed, + canceled, + system, + hue, + time, + npub: meta.map(|m| m.npub), + } +} + +/// Recent unique peers for the home strip (most recent first). +pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String)> { + let store = match wallet.nostr_service() { + Some(s) => s.store.clone(), + None => return vec![], + }; + let mut contacts = store.all_contacts(); + contacts.sort_by_key(|c| std::cmp::Reverse(c.last_paid_at.unwrap_or(c.added_at))); + contacts + .into_iter() + .take(limit) + .map(|c| (display_name(&c), c.hue as usize, c.npub)) + .collect() +} + +/// Local contacts whose petname / nip05 / npub contains `query` (case- +/// insensitive) — the instant, no-network half of the recipient search. +pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, usize, String)> { + let store = match wallet.nostr_service() { + Some(s) => s.store.clone(), + None => return vec![], + }; + let q = query.trim().trim_start_matches('@').to_lowercase(); + if q.is_empty() { + return vec![]; + } + let mut hits: Vec<(String, usize, String)> = store + .all_contacts() + .into_iter() + .filter(|c| { + c.petname + .as_deref() + .map(|p| p.to_lowercase().contains(&q)) + .unwrap_or(false) + || c.nip05 + .as_deref() + .map(|n| n.to_lowercase().contains(&q)) + .unwrap_or(false) + || c.npub.to_lowercase().contains(&q) + }) + .map(|c| (display_name(&c), c.hue as usize, c.npub)) + .collect(); + hits.truncate(limit); + hits +} diff --git a/src/gui/views/goblin/identicon.rs b/src/gui/views/goblin/identicon.rs new file mode 100644 index 00000000..4b0fc069 --- /dev/null +++ b/src/gui/views/goblin/identicon.rs @@ -0,0 +1,106 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Deterministic gradient avatars for anonymous nostr users. +//! +//! `avatar = f(pubkey)`: a two-tone gradient tile seeded by the pubkey, with the +//! Grin mark composited on top. Same key → identical SVG on every device, so +//! there is nothing to upload, store, or sync — each surface regenerates the +//! same bytes locally. The fallback avatar for anyone with no @handle and no +//! kind-0 `picture`, instead of a meaningless lettered tile. +//! +//! Seed = the **lowercase 64-char hex pubkey** hashed as UTF-8. Keep this byte +//! identical to the shared reference port (`identicon.rs` / `avatar.ts`): same +//! SHA-256 input, f64 math, and constants — or two surfaces draw two different +//! avatars for one person. All math is f64 (f32 drifts ±1 per channel vs JS). + +use nostr_sdk::{FromBech32, PublicKey}; +use sha2::{Digest, Sha256}; + +/// The Grin nav mark in its native 61×61 coordinate space. +const GRIN_PATH: &str = "M43.341 20.2793C42.6915 18.8211 42.0862 15.94 40.4204 15.2994C38.2758 14.4747 36.9501 19.8734 36.6342 21.2375H36.3149C35.7742 18.9002 35.0485 15.5878 32.4824 14.85C31.2943 19.8399 33.7235 25.2229 35.9955 29.5411C38.4215 28.3818 39.6035 24.7512 39.8279 22.1956H40.1473L42.7023 29.8605C44.7578 29.2697 45.4729 27.2356 46.2151 25.3893C47.8084 21.4265 49.1453 16.5529 48.1317 12.295C45.0641 13.1637 44.1309 17.5503 43.341 20.2793ZM12.6813 30.4993C15.4263 29.1886 16.7325 25.0399 17.1525 22.1956H17.4719C17.7967 23.5666 18.665 27.1037 20.3781 27.3307C22.5607 27.6195 23.7051 22.7765 23.8593 21.2375H24.1787C24.8746 23.642 25.6079 26.769 28.0112 27.9443C28.8978 24.2204 27.8361 20.249 26.4744 16.7662C26.1243 15.8707 25.4054 13.4562 24.1707 13.4562C22.1478 13.4562 21.0105 18.7885 20.6656 20.2793H20.3462L17.7913 12.6144C13.297 14.7605 10.8557 26.1727 12.6813 30.4993ZM7.89066 34.3317C11.2259 48.8795 26.6098 57.1266 40.4667 50.9832C45.5099 48.7472 49.5104 44.7634 51.8169 39.7611C52.4128 38.4686 53.5834 36.1291 52.9008 34.4333C52.2212 32.7441 45.6297 35.5041 43.9827 36.225C43.7514 36.3278 43.5883 36.5411 43.5503 36.7915C43.4963 37.1457 43.5921 37.5066 43.8153 37.7874C44.0383 38.0681 44.3682 38.2431 44.7256 38.2706C45.9331 38.3635 47.4929 38.4836 47.4929 38.4836C42.4829 48.1813 28.9371 52.4692 19.3881 44.7215C17.2509 42.9877 15.3442 40.9274 14.061 38.4836C13.4404 37.3019 12.8649 35.7906 11.81 34.9797C10.7966 34.2004 9.25919 33.9335 7.89066 34.3317Z"; + +/// Mark spans 90% of the tile; black at 67% opacity (matches the nav styling). +const LOGO_FRAC: f64 = 0.90; +const LOGO_OPACITY: f64 = 0.67; +const GRIN_NATIVE: f64 = 61.0; + +/// Standard HSL → RGB → `#rrggbb`. f64 throughout for cross-port byte-identity. +fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let hp = h / 60.0; + let x = c * (1.0 - ((hp % 2.0) - 1.0).abs()); + let (r, g, b) = match hp.floor() as i32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + let to = |v: f64| ((v + m) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", to(r), to(g), to(b)) +} + +/// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical +/// lowercase hex pubkey used as the seed everywhere. +pub fn to_hex_seed(id: &str) -> String { + if let Ok(pk) = PublicKey::from_bech32(id) { + pk.to_hex() + } else { + id.to_lowercase() + } +} + +/// Gradient stop colors (`#rrggbb`) + rotation angle derived from the seed `hex`. +/// Shared by the Grin-mark avatar and the bare-background variant so both draw +/// the byte-identical gradient for one key. Keep this math in lockstep with the +/// shared reference port. +fn gradient_params(hex: &str) -> (String, String, f64) { + let hash = Sha256::digest(hex.as_bytes()); + let base = ((u16::from(hash[0]) << 8 | u16::from(hash[1])) as f64 / 65_535.0) * 360.0; + let offset = 40.0 + (hash[2] as f64 / 255.0) * 120.0; + let h2 = (base + offset) % 360.0; + let angle = (hash[3] as f64 / 255.0) * 360.0; + let c1 = hsl_to_rgb(base, 0.62, 0.55); + let c2 = hsl_to_rgb(h2, 0.62, 0.42); + (c1, c2, angle) +} + +/// The seeded two-tone gradient WITHOUT the Grin mark — a bare background tile. +/// Used for **named** users, where the app paints the person's initial on top +/// (see `widgets::gradient_letter_avatar`) instead of the Grin mark. Same seed → +/// same background as the anonymous gradient avatar, so one key reads consistently. +pub fn gradient_bg_svg(hex: &str, size: u32) -> String { + let (c1, c2, angle) = gradient_params(hex); + format!( + r##""## + ) +} + +/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase +/// hex pubkey). `id_suffix` makes the gradient element id unique when several +/// are inlined into ONE html document; for a standalone document (how egui +/// rasterizes each one) `""` is fine. +pub fn gradient_avatar_svg(hex: &str, size: u32, id_suffix: &str) -> String { + let (c1, c2, angle) = gradient_params(hex); + + let target = size as f64 * LOGO_FRAC; + let scale = target / GRIN_NATIVE; + let off = (size as f64 - target) / 2.0; + format!( + r##""## + ) +} diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs new file mode 100644 index 00000000..5d07d51f --- /dev/null +++ b/src/gui/views/goblin/mod.rs @@ -0,0 +1,4965 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! The Goblin payment-app-style wallet surface for an open wallet. + +pub mod avatars; +pub mod data; +pub mod identicon; +pub mod onboarding; +pub mod send; +pub mod widgets; + +use eframe::epaint::{CornerRadius, FontId, Stroke}; +use egui::{Align, Color32, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; + +use crate::gui::icons::{ + ARROW_DOWN, ARROW_LEFT, CHECK, CLOCK, COPY, PROHIBIT, QR_CODE, SHARE, USER_CIRCLE, WALLET, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::theme::{self, fonts}; +use crate::gui::views::{Content, TextEdit, View}; +use crate::wallet::Wallet; +use crate::wallet::types::WalletData; + +use self::data::{ActivityItem, activity_items, recent_peers}; +use self::send::SendFlow; +use self::widgets as w; + +/// Goblin navigation tabs. The mobile bar shows Home / Pay / Activity; +/// Receive and Me stay reachable (Pay's Request action, header avatar, +/// desktop sidebar). +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Home, + Pay, + Activity, + Receive, + Me, +} + +/// Goblin wallet content view. +pub struct GoblinWalletView { + tab: Tab, + send: Option, + /// Open transaction receipt by tx id (full-surface overlay). + receipt: Option, + /// Open contact profile by npub hex (full-surface overlay). + profile: Option, + /// Request being reviewed before payment (full-surface hold-to-accept + /// overlay); approving a request goes through this, not a one-tap pay. + approve_review: Option, + /// Hold-to-accept gesture state for the request-review screen. + approve_hold: w::HoldToSend, + /// Amount the last CalculateFee was requested for on the review screen. + approve_fee_for: Option, + /// Request ids already approved this session (double-tap guard). + approving: std::collections::HashSet, + /// Why the last approve failed (e.g. funds confirming), shown above the + /// request list; cleared when a new approve is attempted. + request_error: Option, + /// Identifier of the wallet this view is bound to (reset on change). + wallet_id: Option, + /// Inline username-claim state for the Me tab. + claim: Option, + /// Inline key-rotation state for the Me tab. + rotate: Option, + /// Inline nsec-import state for the Me tab. + import_nsec: Option, + /// Inline "back up identity to a file" flow state. + backup: Option, + /// Inline "change name authority" editor state. + name_authority: Option, + /// Amount being entered on the Pay tab. + pay_amount: String, + /// When set, the over-balance "no" animation is playing: the start time (egui + /// input seconds) the user pressed Pay without enough funds. Drives a brief + /// red flash + horizontal shake of the amount, then clears itself. + pay_shake: Option, + /// Amount being requested, shown on the Receive screen. + request_amount: Option, + /// Sub-page open inside the Settings tab. + settings_page: SettingsPage, + /// GRIM's native node-connections screen (embedded under Advanced). + grim_connections: crate::gui::views::network::ConnectionsContent, + /// Inline state for the Advanced settings page (recovery/repair/delete). + advanced: AdvancedState, + /// One-shot signal to the wallet host: deselect this wallet (return to the + /// chooser) without locking it, so another can be picked. Consumed by + /// [`WalletContent::take_switch_request`]. + switch_requested: bool, + /// Inputs for adding an external node connection. + node_url_input: String, + node_secret_input: String, + /// Relay list being edited and the add-relay input. + relay_edit: Vec, + relay_input: String, + /// Transient "Copied" feedback on the Receive buttons: which one + /// (0 = npub, 1 = grin address) and when it was clicked. + receive_copied: Option<(u8, std::time::Instant)>, + /// Avatar texture layer (disk cache + background fetches). + avatars: avatars::AvatarTextures, + /// Manual slatepack page state (GRIM-native send/receive fallback). + slatepack: SlatepackManual, + /// Receipt "Cancel payment" tap-twice confirm: the tx_id awaiting a second + /// confirming tap (cleared when another receipt opens or it's fired). + cancel_confirm: Option, + /// Outcome of the last manual cancel, shown transiently on the receipt. + cancel_msg: Option<(crate::nostr::CancelOutcome, std::time::Instant)>, + /// Transient "Copied" flash for the settings backup card (npub/keys). + copy_flash: Option, +} + +/// Sub-pages of the Settings tab. +#[derive(Clone, Copy, PartialEq, Eq)] +enum SettingsPage { + Main, + Node, + /// GRIM's native node-connections screen, embedded. + Connections, + Relays, + Nips, + Pairing, + Slatepack, + Privacy, + Advanced, +} + +/// Inline state for the Advanced (wallet-recovery) settings page: the +/// recovery-phrase reveal and the two-step confirms for destructive actions. +#[derive(Default)] +struct AdvancedState { + /// Password typed to reveal the grin recovery phrase. + reveal_pass: String, + /// The revealed seed words, held only while shown (cleared on hide/back). + revealed: Option, + /// Set when the entered password didn't decrypt the seed. + wrong_pass: bool, + /// Armed "really restore?" confirm. + confirm_restore: bool, + /// Armed "really repair?" confirm (repair takes a few minutes). + confirm_repair: bool, + /// Armed "really delete?" confirm. + confirm_delete: bool, +} + +/// Inputs and last result for the manual slatepack page (GRIM's native flow, +/// surfaced as an advanced fallback under Settings → Wallet). +#[derive(Default)] +struct SlatepackManual { + /// Pasted incoming slatepack to receive/finalize. + paste: String, + /// Outgoing amount (human grin) for a manually-created payment. + amount: String, + /// Optional recipient slatepack address for the outgoing payment. + address: String, + /// Produced slatepack text to copy and hand over (send, or a receive reply). + result: String, + /// Transient status line (e.g. "Finalizing…") under the actions. + status: Option, + /// Last error to show in the danger color. + error: Option, +} + +impl Default for GoblinWalletView { + fn default() -> Self { + Self { + tab: Tab::Home, + send: None, + receipt: None, + profile: None, + approve_review: None, + approve_hold: w::HoldToSend::default(), + approve_fee_for: None, + approving: std::collections::HashSet::new(), + request_error: None, + wallet_id: None, + claim: None, + rotate: None, + import_nsec: None, + backup: None, + name_authority: None, + pay_amount: String::new(), + pay_shake: None, + request_amount: None, + settings_page: SettingsPage::Main, + grim_connections: Default::default(), + advanced: AdvancedState::default(), + switch_requested: false, + node_url_input: String::new(), + node_secret_input: String::new(), + relay_edit: Vec::new(), + relay_input: String::new(), + receive_copied: None, + slatepack: SlatepackManual::default(), + avatars: avatars::AvatarTextures::default(), + cancel_confirm: None, + cancel_msg: None, + copy_flash: None, + } + } +} + +/// Inline key-rotation flow state (two warnings + typed confirmation). +struct RotateState { + /// 1 = warning, 2 = confirm (RESET + password), 3 = working, + /// 4 = done (new npub), 5 = error (message). + stage: u8, + reset_input: String, + password: String, + new_npub: String, + error: String, + result: std::sync::Arc>>>, +} + +impl Default for RotateState { + fn default() -> Self { + Self { + stage: 1, + reset_input: String::new(), + password: String::new(), + new_npub: String::new(), + error: String::new(), + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } +} + +/// Inline nsec-import flow state (restore path for the random-key model). +struct ImportState { + /// 1 = form, 3 = working, 4 = done, 5 = error. + stage: u8, + nsec: String, + password: String, + backup_password: String, + new_npub: String, + error: String, + /// A native file pick is in flight (Android returns the path asynchronously). + picking: bool, + result: std::sync::Arc>>>, +} + +impl Default for ImportState { + fn default() -> Self { + Self { + stage: 1, + nsec: String::new(), + password: String::new(), + backup_password: String::new(), + new_npub: String::new(), + error: String::new(), + picking: false, + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } +} + +/// Inline "change name authority" (federation) editor state. +#[derive(Default)] +struct NameAuthorityState { + /// Server URL being typed (e.g. https://other.example). + input: String, + /// Validation error to show. + error: Option, +} + +/// Inline "back up identity to a file" flow state. +#[derive(Default)] +struct BackupState { + /// Wallet password to unseal the identity for the backup. + password: String, + /// Error to show (wrong password / write failed). + error: Option, + /// The backup file was created. + done: bool, +} + +/// Inline username-claim widget state. +struct ClaimState { + input: String, + checking: bool, + available: Option, + result: std::sync::Arc>>, + message: Option, + /// The are-you-sure gate before releasing a username. + confirm_release: bool, +} + +enum ClaimMsg { + Availability(crate::nostr::nip05::Availability), + Registered(String), + Released, + Error(String), +} + +/// Map an availability probe to user-facing state: `None` availability +/// means the check itself failed — never present that as "Taken". +fn availability_feedback(avail: crate::nostr::nip05::Availability) -> (Option, String) { + use crate::nostr::nip05::Availability::*; + match avail { + Available => ( + Some(true), + t!("goblin.settings.avail_available").to_string(), + ), + Taken => (Some(false), t!("goblin.settings.avail_taken").to_string()), + Reserved => ( + Some(false), + t!("goblin.settings.avail_reserved").to_string(), + ), + Invalid => (Some(false), t!("goblin.settings.avail_invalid").to_string()), + Quarantined => ( + Some(false), + t!("goblin.settings.avail_quarantined").to_string(), + ), + Unknown => (None, t!("goblin.settings.avail_unknown").to_string()), + } +} + +impl Default for ClaimState { + fn default() -> Self { + Self { + input: String::new(), + checking: false, + available: None, + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + message: None, + confirm_release: false, + } + } +} + +impl GoblinWalletView { + /// Whether an overlay flow (send) is active. + pub fn overlay_active(&self) -> bool { + self.send.is_some() + } + + /// Take the pending "switch wallet" request (set by the Settings button), + /// resetting it. The host deselects the wallet when this returns true. + pub fn take_switch_request(&mut self) -> bool { + std::mem::take(&mut self.switch_requested) + } + + /// Handle a back navigation; returns true if not consumed. + pub fn on_back(&mut self) -> bool { + if self.receipt.is_some() { + self.receipt = None; + return false; + } + if self.profile.is_some() { + self.profile = None; + return false; + } + if self.send.is_some() { + self.send = None; + return false; + } + if self.tab == Tab::Me && self.settings_page != SettingsPage::Main { + self.settings_page = SettingsPage::Main; + return false; + } + if self.tab != Tab::Home { + self.tab = Tab::Home; + return false; + } + true + } + + /// Render the full Goblin surface for an open wallet. + pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + + // Reset transient UI state when the bound wallet changes, so a + // half-filled send or claim never leaks across a wallet switch. + let id = wallet.identifier(); + if self.wallet_id.as_deref() != Some(id.as_str()) { + self.wallet_id = Some(id); + self.tab = Tab::Home; + self.send = None; + self.receipt = None; + self.profile = None; + self.claim = None; + self.rotate = None; + self.import_nsec = None; + self.approve_review = None; + self.approve_hold = w::HoldToSend::default(); + self.approve_fee_for = None; + self.approving.clear(); + self.request_error = None; + self.pay_amount.clear(); + self.request_amount = None; + self.settings_page = SettingsPage::Main; + self.advanced = AdvancedState::default(); + } + + // Send flow takes the full surface when active. + if let Some(send) = &mut self.send { + let done = send.ui(ui, wallet, cb, &mut self.avatars); + if done { + let receipt_npub = send.receipt_npub.clone(); + self.send = None; + // "Receipt" on the success screen opens the latest tx with them. + if let Some(npub) = receipt_npub { + if let Some(item) = data::history_with(wallet, &npub).into_iter().next() { + self.receipt = Some(item.tx_id); + } + } + } + return; + } + // Receipt + contact profile are full-surface overlays as well. + if let Some(tx_id) = self.receipt { + if self.receipt_ui(ui, wallet, tx_id) { + self.receipt = None; + } + return; + } + if let Some(npub) = self.profile.clone() { + if self.profile_ui(ui, wallet, cb, &npub) { + self.profile = None; + } + return; + } + // Approving a request opens a full-surface review with hold-to-accept. + if self.approve_review.is_some() { + if self.approve_review_ui(ui, wallet) { + self.approve_review = None; + self.approve_fee_for = None; + } + return; + } + + // Desktop (wide) shows a left sidebar (shell B); narrow/mobile shows a + // bottom tab bar (shell A). Both drive the same Tab state and screens. + let wide_desktop = View::is_desktop() && ui.available_width() >= 720.0; + if wide_desktop { + egui::SidePanel::left("goblin_sidebar") + .resizable(false) + .exact_width(244.0) + .frame(egui::Frame { + fill: t.bg, + stroke: Stroke::new(1.0, t.line), + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + 16.0) as i8, + right: 16, + top: (View::get_top_inset() + 28.0) as i8, + bottom: 20, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + self.sidebar_ui(ui, wallet); + }); + } else { + let bottom_inset = View::get_bottom_inset(); + egui::TopBottomPanel::bottom("goblin_tabs") + .frame(egui::Frame { + // Bottom strip goes yellow on the Pay surface, like the body. + fill: if self.tab == Tab::Pay { + theme::YELLOW.bg + } else { + t.bg + }, + inner_margin: Margin { + left: 16, + right: 16, + top: 10, + bottom: (12.0 + bottom_inset) as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + self.tab_bar_ui(ui, wallet); + }); + } + + // Central content. The Pay tab is painted in the yellow theme (Cash + // App-style brand surface) regardless of the user's chosen theme: a + // scoped override held across the whole panel so its fill AND every + // widget inside pick up the yellow tokens together. + let pay = self.tab == Tab::Pay; + let _pay_theme = pay.then(|| theme::scoped(theme::ThemeKind::Yellow)); + // Bright yellow top → dark status-bar icons (see status_bar_white_icons). + theme::set_status_surface_yellow(pay); + let panel_fill = if pay { theme::YELLOW.bg } else { t.bg }; + egui::CentralPanel::default() + .frame(egui::Frame { + fill: panel_fill, + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + 20.0) as i8, + right: (View::get_right_inset() + 20.0) as i8, + top: (View::get_top_inset() + 8.0) as i8, + bottom: 0, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.tab { + Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop), + Tab::Pay => self.pay_ui(ui, wallet, cb), + Tab::Activity => self.activity_ui(ui, wallet, cb), + Tab::Receive => self.receive_ui(ui, wallet, cb), + Tab::Me => self.me_ui(ui, wallet, cb), + }); + }); + } + + /// 3-item bar: Wallet · Pay (center ツ) · Activity. A floating pill on most + /// surfaces; chromeless (no pill/shadow, dark items on yellow) on the Pay tab. + fn tab_bar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + let pay = self.tab == Tab::Pay; + let yt = &theme::YELLOW; + let has_requests = wallet + .nostr_service() + .map(|s| !s.store.pending_requests().is_empty()) + .unwrap_or(false); + + let bar_h = 64.0; + let bar_w = ui.available_width().min(340.0); + let margin = ((ui.available_width() - bar_w) / 2.0).max(0.0); + ui.horizontal(|ui| { + ui.add_space(margin); + let (bar_rect, _) = ui.allocate_exact_size(Vec2::new(bar_w, bar_h), Sense::hover()); + // Soft shadow + floating pill — omitted on the Pay surface. + if !pay { + let shadow = bar_rect.translate(Vec2::new(0.0, 3.0)).expand(2.0); + ui.painter().rect_filled( + shadow, + CornerRadius::same(34), + Color32::from_black_alpha(70), + ); + ui.painter().rect( + bar_rect, + CornerRadius::same(32), + t.surface, + Stroke::new(1.0, t.line), + egui::StrokeKind::Inside, + ); + } + + let cell = bar_w / 3.0; + let tabs = [ + (Tab::Home, Some(WALLET), false), + (Tab::Pay, None, false), + (Tab::Activity, Some(CLOCK), has_requests), + ]; + for (i, (tab, icon, badge)) in tabs.into_iter().enumerate() { + let rect = egui::Rect::from_min_size( + bar_rect.min + Vec2::new(i as f32 * cell, 0.0), + Vec2::new(cell, bar_h), + ); + let resp = ui.interact(rect, ui.id().with(("goblin_tab", i)), Sense::click()); + let active = self.tab == tab; + match icon { + Some(icon) => { + // Icon-only; the active tab gets a circular highlight (only + // where there's a pill — not on the chromeless Pay surface). + if active && !pay { + ui.painter().circle_filled(rect.center(), 22.0, t.surface2); + } + let color = if pay { + if active { yt.text } else { yt.text_dim } + } else if active { + t.surface_text + } else { + t.surface_text_mute + }; + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon, + FontId::new(23.0, fonts::regular()), + color, + ); + } + None => { + // Center Pay action: accent ツ puck off the Pay surface; a + // chromeless dark ツ on it (payment-app style). + if !pay { + let grow = if active || resp.hovered() { 1.0 } else { 0.0 }; + ui.painter().circle_filled( + rect.center(), + 24.0 + grow, + if resp.hovered() { + t.accent_dark + } else { + t.accent + }, + ); + } + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + w::TSU, + // Noto Sans JP's clean ツ — the Pay puck mark, only here. + FontId::new(31.0, egui::FontFamily::Name("noto-tsu".into())), + if pay { yt.text } else { t.accent_ink }, + ); + } + } + if badge { + ui.painter() + .circle_filled(rect.center() + Vec2::new(13.0, -13.0), 4.5, t.neg); + } + if resp.clicked() { + self.tab = tab; + } + } + }); + } + + /// Desktop left sidebar: wordmark, nav items, profile card. + fn sidebar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + // Wordmark. + ui.horizontal(|ui| { + widgets_logo(ui); + ui.add_space(8.0); + ui.label( + RichText::new("goblin") + .font(FontId::new(20.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(28.0); + + let has_requests = wallet + .nostr_service() + .map(|s| !s.store.pending_requests().is_empty()) + .unwrap_or(false); + // (tab, icon, label, badge) + let items = [ + (Tab::Home, WALLET, t!("goblin.home.nav_wallet"), false), + ( + Tab::Pay, + crate::gui::icons::ARROW_UP, + t!("goblin.home.nav_pay"), + false, + ), + ( + Tab::Activity, + CLOCK, + t!("goblin.home.nav_activity"), + has_requests, + ), + ( + Tab::Receive, + ARROW_DOWN, + t!("goblin.home.nav_receive"), + false, + ), + (Tab::Me, USER_CIRCLE, t!("goblin.home.nav_settings"), false), + ]; + for (tab, icon, label, badge) in items { + let active = tab == self.tab; + let (rect, resp) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::click()); + if active || resp.hovered() { + ui.painter().rect_filled( + rect, + eframe::epaint::CornerRadius::same(12), + if active { t.surface2 } else { t.hover }, + ); + } + // The active pill is a surface; on-surface ink keeps it readable + // in the yellow theme (dark pill on bright bg). + let color = if active { t.surface_text } else { t.text_dim }; + ui.painter().text( + rect.left_center() + Vec2::new(14.0, 0.0), + egui::Align2::LEFT_CENTER, + icon, + FontId::new(20.0, fonts::regular()), + color, + ); + ui.painter().text( + rect.left_center() + Vec2::new(44.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + FontId::new( + 15.0, + if active { + fonts::semibold() + } else { + fonts::medium() + }, + ), + color, + ); + if badge { + ui.painter() + .circle_filled(rect.right_center() - Vec2::new(14.0, 0.0), 4.0, t.neg); + } + if resp.clicked() { + self.tab = tab; + } + ui.add_space(4.0); + } + + // Node status + profile cards pinned to the bottom (node info lives + // here so the surface needs no separate network column). Each card is + // its own shortcut: the node card opens the Node menu, the identity + // chip opens identity settings. + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + let width = ui.available_width(); + ui.allocate_ui_with_layout( + Vec2::new(width, 196.0), + Layout::top_down(Align::Min), + |ui| { + // Node status card → Settings → Node menu. + let node = ui + .scope(|ui| self.node_card_ui(ui, wallet)) + .response + .interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand); + if node.clicked() { + self.tab = Tab::Me; + self.settings_page = SettingsPage::Node; + } + ui.add_space(8.0); + let (handle, connected, npub_hex) = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + let h = id + .nip05 + .clone() + .map(|n| n.split('@').next().unwrap_or("").to_string()) + .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); + (h, s.is_connected(), hex_of(&id.npub)) + }) + .unwrap_or_else(|| { + ( + t!("goblin.home.anonymous").to_string(), + false, + String::new(), + ) + }); + let hue = data::hue_of(&npub_hex); + let tex = self.handle_tex(ui.ctx(), wallet, &handle); + // Identity chip → identity settings. + let id_resp = ui + .scope(|ui| { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + w::avatar_any(ui, &handle, &npub_hex, 28.0, hue, tex.as_ref()); + ui.add_space(10.0); + ui.vertical(|ui| { + // Scale the handle to its length: short @names get a + // big, legible size; a long npub shrinks to stay on + // one line. + let len = handle.chars().count() as f32; + let handle_font = + (20.0 - (len - 6.0).max(0.0) * 0.7).clamp(11.0, 16.0); + ui.label( + RichText::new(&handle) + .font(FontId::new(handle_font, fonts::semibold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(if connected { + t!("goblin.home.connected_nym") + } else if crate::nym::is_ready() { + t!("goblin.home.nym_ready") + } else { + t!("goblin.home.connecting_nym") + }) + .font(FontId::new(11.0, fonts::regular())) + .color(t.surface_text_mute), + ); + }); + }); + }); + }) + .response + .interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand); + if id_resp.clicked() { + self.tab = Tab::Me; + self.settings_page = SettingsPage::Main; + } + }, + ); + }); + } + + /// Avatar texture for a display handle ("@name"); None for non-handles + /// (anonymous identities keep their letter puck). + fn handle_tex( + &mut self, + ctx: &egui::Context, + wallet: &Wallet, + handle: &str, + ) -> Option { + // Avatars live on the nip05 server, keyed by handle. Handles no longer + // carry an '@'; skip bare-npub and empty display names (no avatar there). + if handle.is_empty() || handle.starts_with("npub1") { + return None; + } + let server = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server())?; + self.avatars.texture_for(ctx, &server, handle) + } + + /// Compact node status card: sync state dot, block height, connection. + fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + let height = wallet + .get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0); + // Distinguish "scanning" from "can't reach the node": a flaky + // external node otherwise reads as syncing forever. + let error = wallet.sync_error(); + let synced = height > 0 && !wallet.syncing() && !error; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); + ui.painter().circle_filled( + dot.center(), + 4.0, + if error { + t.neg + } else if synced { + t.pos + } else { + t.accent + }, + ); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.label( + RichText::new(if error { + t!("goblin.home.cant_reach_node") + } else if synced { + t!("goblin.home.node_synced") + } else { + t!("goblin.home.syncing") + }) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + // Three lines: status, block height, then the node host + // on its own line so it never truncates the height. + let height = wallet + .get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0); + ui.label( + RichText::new(if height > 0 { + t!("goblin.home.block", height => fmt_thousands(height)).to_string() + } else { + t!("goblin.home.waiting_for_chain").to_string() + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add( + egui::Label::new( + RichText::new(node_host(wallet)) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ) + .truncate(), + ); + }); + }); + }); + } + + fn home_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + wide: bool, + ) { + let data = wallet.get_data(); + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + // Mobile header: wordmark left, avatar (opens settings) right. + if !wide { + ui.add_space(10.0); + let (header_handle, header_hex) = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + let hex = hex_of(&id.npub); + // With a verified handle show "@name"; otherwise fall back to + // the short npub so avatar_any draws the deterministic gradient + // (it keys the gradient branch off a leading "npub"), not a + // meaningless lettered tile. + let h = id + .nip05 + .clone() + .map(|n| n.split('@').next().unwrap_or("").to_string()) + .unwrap_or_else(|| data::short_npub(&hex)); + (h, hex) + }) + .unwrap_or_else(|| ("N".to_string(), String::new())); + let header_hue = data::hue_of(&header_hex); + let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle); + ui.horizontal(|ui| { + widgets_logo(ui); + ui.add_space(8.0); + ui.label( + RichText::new("goblin") + .font(FontId::new(18.0, fonts::bold())) + .color(theme::tokens().text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if w::avatar_any( + ui, + &header_handle, + &header_hex, + 36.0, + header_hue, + header_tex.as_ref(), + ) + .clicked() + { + self.tab = Tab::Me; + } + // Scan-to-pay, left of the avatar per the refs. + ui.add_space(10.0); + let (rect, resp) = + ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled( + rect.center(), + 18.0, + theme::tokens().surface2, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + QR_CODE, + FontId::new(17.0, fonts::regular()), + theme::tokens().surface_text, + ); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + if resp.clicked() { + let mut flow = SendFlow::default(); + flow.request_scan(); + self.send = Some(flow); + } + }); + }); + ui.add_space(28.0); + } else { + ui.add_space(48.0); + } + let (total, spendable) = data + .as_ref() + .map(|d| (d.info.total, d.info.amount_currently_spendable)) + .unwrap_or((0, 0)); + w::balance_hero(ui, total, spendable, fiat_line(&data).as_deref(), 56.0); + ui.add_space(20.0); + let (send, receive) = w::send_receive(ui); + if send { + self.send = Some(SendFlow::default()); + } + if receive { + self.tab = Tab::Receive; + } + ui.add_space(24.0); + + // Recent peers strip. + self.peers_strip_ui(ui, wallet, "goblin_peers_home"); + + // Recent activity. + w::kicker(ui, &t!("goblin.home.activity")); + ui.add_space(6.0); + let items = activity_items(wallet); + if items.is_empty() { + empty_state( + ui, + &t!("goblin.home.empty_title"), + &t!("goblin.home.empty_sub"), + ); + } else { + for item in items.iter().take(6) { + self.activity_item_ui(ui, item, wallet, cb); + } + } + ui.add_space(16.0); + }); + } + + /// Horizontal recent-contacts strip; tapping one starts a prefilled send. + fn peers_strip_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, salt: &str) { + let peers = recent_peers(wallet, 8); + if peers.is_empty() { + return; + } + let texs: Vec> = peers + .iter() + .map(|(name, _, _)| self.handle_tex(ui.ctx(), wallet, name)) + .collect(); + w::kicker(ui, &t!("goblin.home.recent")); + ui.add_space(12.0); + ScrollArea::horizontal() + .id_salt(salt.to_string()) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + for ((name, hue, npub), tex) in peers.iter().zip(texs.iter()) { + // Fixed-width centered cell so the name sits centered under the + // avatar (not left-aligned to a wider label). + ui.allocate_ui_with_layout( + Vec2::new(72.0, 78.0), + Layout::top_down(Align::Center), + |ui| { + let resp = w::avatar_any(ui, name, npub, 48.0, *hue, tex.as_ref()); + ui.add_space(6.0); + let chars: Vec = name.chars().collect(); + let short: String = if chars.len() > 8 { + format!("{}…", chars[..8].iter().collect::()) + } else { + name.to_string() + }; + ui.label( + RichText::new(short).font(FontId::new(12.0, fonts::medium())), + ); + if resp.clicked() { + self.profile = Some(npub.clone()); + } + }, + ); + ui.add_space(12.0); + } + }); + }); + ui.add_space(20.0); + } + + /// Pay tab: amount-first combined pay/request surface. + fn pay_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + ui.add_space(8.0); + // Header identity for the avatar (→ settings), mirroring the Home header. + let (header_handle, header_hex) = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + let hex = hex_of(&id.npub); + let h = id + .nip05 + .clone() + .map(|n| n.split('@').next().unwrap_or("").to_string()) + .unwrap_or_else(|| data::short_npub(&hex)); + (h, hex) + }) + .unwrap_or_else(|| ("N".to_string(), String::new())); + let header_hue = data::hue_of(&header_hex); + let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle); + ui.horizontal(|ui| { + // Goblin mark (left), sized to match the right-side controls. + ui.add( + egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg")) + .tint(t.text) + .fit_to_exact_size(Vec2::splat(40.0)), + ); + // Right cluster: scan QR (black, no background) then the profile + // picture at the far right; all three controls about the same size. + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if w::avatar_any( + ui, + &header_handle, + &header_hex, + 40.0, + header_hue, + header_tex.as_ref(), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + self.tab = Tab::Me; + } + ui.add_space(12.0); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(44.0), Sense::click()); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + QR_CODE, + FontId::new(38.0, fonts::regular()), + t.text, + ); + if resp + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text(t!("goblin.home.scan_to_pay").to_string()) + .clicked() + { + let mut f = SendFlow::default(); + f.prefill_amount(self.pay_amount.clone()); + f.request_scan(); + self.pay_amount.clear(); + self.send = Some(f); + } + }); + }); + + // Big centered amount. + let display = if self.pay_amount.is_empty() { + "0".to_string() + } else { + self.pay_amount.clone() + }; + let tall = ui.available_height() > 560.0; + // Over-balance is NOT shown while typing — requesting more than you hold is + // valid, and reddening digits mid-entry reads as an error when it isn't. + // The only feedback is on the Pay press: a brief red flash + shake + buzz + // (see the Pay button below). `spendable` is read there too. + let spendable = wallet + .get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + // Drive the "can't pay that" animation if it's running. + let now = ui.input(|i| i.time); + const SHAKE_DUR: f64 = 0.45; + if self.pay_shake.is_some_and(|s| now - s >= SHAKE_DUR) { + self.pay_shake = None; + } + ui.add_space(if tall { 56.0 } else { 24.0 }); + if let Some(start) = self.pay_shake { + ui.ctx().request_repaint(); // keep the animation ticking + let p = ((now - start) / SHAKE_DUR).clamp(0.0, 1.0) as f32; + // Damped horizontal oscillation, amplitude decaying to zero. + let dx = 14.0 * (1.0 - p) * (p * std::f32::consts::PI * 9.0).sin(); + // Red flash that eases back to the normal ink over the shake. + let num = lerp_color(t.neg, t.text, p); + let mark = lerp_color(t.neg, t.text_dim, p); + w::amount_text_centered_shifted(ui, &display, 76.0, num, mark, dx); + } else { + w::amount_text_centered(ui, &display, 76.0); + } + if let Ok(grin) = display.parse::() { + if let Some(preview) = pairing_preview(grin) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(preview) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + } + } + // Drop the keypad toward the bottom on phone layouts (thumb reach) so it + // isn't stranded in the middle with a big empty gap below it. + let narrow = ui.available_width() < 700.0; + let drop = if narrow { + ((ui.available_height() - 430.0) * 0.6).max(0.0) + } else { + 0.0 + }; + ui.add_space(if tall { 32.0 } else { 16.0 } + drop); + + // Numpad at narrow (mobile-shell) widths, typed input on the wide + // desktop layout — gate by width like the shell itself, or narrow + // desktop windows get neither input. + let typed_hint = !narrow && self.pay_amount.is_empty(); + if narrow { + w::numpad(ui, &mut self.pay_amount, cb); + } else { + w::amount_typed_input(ui, &mut self.pay_amount); + if typed_hint { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.home.type_amount")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_mute), + ); + }); + } + } + ui.add_space(20.0); + + // Request | Pay actions, half width each. + let valid = grin_core::core::amount_from_hr_string(&self.pay_amount) + .map(|a| a > 0) + .unwrap_or(false); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + if w::big_action(ui, &t!("goblin.home.request"), true).clicked() && valid { + // Open the request flow: pick a contact, then DM them a + // grin Invoice1 they can approve to pay. + let f = SendFlow::new_request(self.pay_amount.clone()); + self.pay_amount.clear(); + self.send = Some(f); + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + if w::big_action(ui, &t!("goblin.home.pay"), false).clicked() && valid { + let over = grin_core::core::amount_from_hr_string(&self.pay_amount) + .map(|a| a > spendable) + .unwrap_or(false); + if over { + // "No, you can't pay that": shake + flash the amount red + // and buzz the phone. Nothing is reddened while typing. + self.pay_shake = Some(now); + cb.vibrate_error(); + } else { + let mut f = SendFlow::default(); + f.prefill_amount(self.pay_amount.clone()); + self.pay_amount.clear(); + self.send = Some(f); + } + } + }, + ); + }); + // Skip when the "Type an amount" hint is already showing above. + if !valid && !typed_hint { + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.home.enter_amount")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + }); + } + } + + /// Round back button + title for full-surface overlays. Returns true on tap. + fn overlay_back_header(ui: &mut egui::Ui, title: &str) -> bool { + let t = theme::tokens(); + let mut back = false; + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + ARROW_LEFT, + FontId::new(16.0, fonts::regular()), + t.text, + ); + back = resp + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked(); + ui.add_space(12.0); + ui.label( + RichText::new(title) + .font(FontId::new(18.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(12.0); + back + } + + /// Full-surface transaction receipt: GRIM metadata joined with the nostr + /// counterparty + note. Tapping the counterparty opens their profile. + fn receipt_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, tx_id: u32) -> bool { + let t = theme::tokens(); + let d = data::receipt_detail(wallet, tx_id); + let tex = d + .as_ref() + .and_then(|d| self.handle_tex(ui.ctx(), wallet, &d.title)); + let mut close = false; + let mut open_profile: Option = None; + egui::CentralPanel::default() + .frame(egui::Frame { + fill: t.bg, + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + 20.0) as i8, + right: (View::get_right_inset() + 20.0) as i8, + top: (View::get_top_inset() + 12.0) as i8, + bottom: (View::get_bottom_inset() + 12.0) as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| { + if Self::overlay_back_header(ui, &t!("goblin.receipt.title")) { + close = true; + } + let Some(d) = d else { + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.receipt.not_found")) + .font(FontId::new(15.0, fonts::regular())) + .color(t.text_dim), + ); + }); + return; + }; + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(8.0); + ui.vertical_centered(|ui| { + let resp = w::avatar_any( + ui, + &d.title, + d.npub.as_deref().unwrap_or(""), + 64.0, + d.hue, + tex.as_ref(), + ); + ui.add_space(10.0); + ui.label( + RichText::new(&d.title) + .font(FontId::new(22.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(2.0); + ui.label( + RichText::new(View::format_time(d.time)) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + if let Some(note) = &d.note { + ui.add_space(2.0); + ui.label( + RichText::new(t!("goblin.receipt.for_note", note => note)) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } + ui.add_space(14.0); + w::amount_text_centered(ui, &w::amount_str(d.amount), 56.0); + if resp.clicked() { + if let Some(npub) = &d.npub { + open_profile = Some(npub.clone()); + } + } + }); + ui.add_space(20.0); + w::kicker(ui, &t!("goblin.receipt.details")); + ui.add_space(10.0); + w::card(ui, |ui| { + let (status, sub): (String, String) = if d.canceled { + ( + t!("goblin.receipt.canceled").to_string(), + if d.incoming { + t!("goblin.receipt.expired").to_string() + } else { + t!("goblin.receipt.funds_returned").to_string() + }, + ) + } else if let Some((c, r)) = d.confs { + // On-chain but still maturing toward the spendable + // threshold — show the live X/N count (grin marks a + // tx confirmed at one block; spendable takes N). + if c == 0 && !d.incoming && d.npub.is_some() { + // Sent but not yet picked up / mined. + ( + t!("goblin.receipt.pending").to_string(), + t!("goblin.receipt.waiting_to_receive", name => d.title) + .to_string(), + ) + } else { + ( + t!("goblin.receipt.pending").to_string(), + t!("goblin.receipt.confs", c => c, r => r).to_string(), + ) + } + } else if d.confirmed { + ( + t!("goblin.receipt.complete").to_string(), + if d.incoming { + t!("goblin.receipt.payment_received").to_string() + } else { + t!("goblin.receipt.payment_sent").to_string() + }, + ) + } else { + ( + t!("goblin.receipt.pending").to_string(), + t!("goblin.receipt.waiting_to_confirm").to_string(), + ) + }; + w::info_row(ui, &status, &sub); + if d.has_identity { + let (to, from) = if d.incoming { + (t!("goblin.receipt.you").to_string(), d.title.clone()) + } else { + (d.title.clone(), t!("goblin.receipt.you").to_string()) + }; + w::info_row(ui, &t!("goblin.receipt.to"), &to); + w::info_row(ui, &t!("goblin.receipt.from"), &from); + } + if let Some(npub) = &d.npub { + w::info_row( + ui, + &t!("goblin.receipt.nostr"), + &data::short_npub(npub), + ); + } + // Only the SENDER pays a network fee, so the row only makes + // sense on outgoing payments. A received payment has no fee + // (data sets it to None) — hide the row entirely instead of + // showing a confusing "—". + if let Some(fee_amount) = d.fee { + let fee = if fee_amount == 0 { + t!("goblin.receipt.fee_none").to_string() + } else { + format!("{}{}", w::amount_str(fee_amount), w::TSU) + }; + w::info_row(ui, &t!("goblin.receipt.network_fee"), &fee); + } + w::info_row( + ui, + &t!("goblin.receipt.privacy"), + &t!("goblin.receipt.privacy_value"), + ); + if let Some(sid) = &d.slate_id { + let short = if sid.len() > 13 { + format!("{}…{}", &sid[..8], &sid[sid.len() - 4..]) + } else { + sid.clone() + }; + w::info_row(ui, &t!("goblin.receipt.transaction"), &short); + } + }); + // Withdraw a request we sent that hasn't been paid yet: + // cancel the local invoice and tell the payer (a void + // message). Requests are messages; payments are final. + let cancelable_request = d + .slate_id + .as_ref() + .and_then(|sid| { + wallet.nostr_service().and_then(|s| s.store.tx_meta(sid)) + }) + .map(|m| { + m.direction == crate::nostr::NostrTxDirection::RequestedByUs + && matches!( + m.status, + crate::nostr::NostrSendStatus::Created + | crate::nostr::NostrSendStatus::AwaitingI2 + ) + }) + .unwrap_or(false) && !d.canceled + && !d.confirmed; + if cancelable_request { + ui.add_space(16.0); + if w::big_action(ui, &t!("goblin.receipt.cancel_request"), true) + .clicked() + { + if let Some(sid) = &d.slate_id { + wallet.task( + crate::wallet::types::WalletTask::NostrCancelOutgoing( + sid.clone(), + ), + ); + } + close = true; + } + } + // Reclaim a payment WE sent that the recipient never + // completed: cancel the grin tx to unlock our funds, mark + // it cancelled, best-effort void. Appears after the grace + // window (or immediately if it never reached a relay). + let send_meta = d.slate_id.as_ref().and_then(|sid| { + wallet.nostr_service().and_then(|s| s.store.tx_meta(sid)) + }); + let grace = wallet + .nostr_service() + .map(|s| s.config.read().cancel_grace_secs()) + .unwrap_or(600); + let cancelable_send = send_meta + .as_ref() + .map(|m| { + m.direction == crate::nostr::NostrTxDirection::Sent + && matches!( + m.status, + crate::nostr::NostrSendStatus::Created + | crate::nostr::NostrSendStatus::AwaitingS2 + | crate::nostr::NostrSendStatus::SendFailed + ) && (matches!( + m.status, + crate::nostr::NostrSendStatus::SendFailed + ) || crate::nostr::unix_time() - m.created_at > grace) + }) + .unwrap_or(false) && !d.canceled + && !d.confirmed; + if cancelable_send { + ui.add_space(16.0); + let confirming = self.cancel_confirm == Some(d.tx_id); + let label = if confirming { + t!("goblin.receipt.cancel_send_confirm") + } else { + t!("goblin.receipt.cancel_send") + }; + if w::big_action(ui, &label, true).clicked() { + if confirming { + if let Some(sid) = &d.slate_id { + wallet.task( + crate::wallet::types::WalletTask::NostrCancelSend( + sid.clone(), + ), + ); + } + self.cancel_confirm = None; + } else { + self.cancel_confirm = Some(d.tx_id); + } + } + } else { + self.cancel_confirm = None; + } + // Transient outcome notice, set async by the task handler. + if let Some(outcome) = + wallet.nostr_service().and_then(|s| s.take_cancel_notice()) + { + self.cancel_msg = Some((outcome, std::time::Instant::now())); + } + if let Some((outcome, at)) = self.cancel_msg { + if at.elapsed().as_secs() < 5 { + ui.add_space(10.0); + let (msg, col) = match outcome { + crate::nostr::CancelOutcome::Cancelled => { + (t!("goblin.receipt.cancel_send_done"), t.pos) + } + crate::nostr::CancelOutcome::AlreadyCompleted => { + (t!("goblin.receipt.cancel_send_too_late"), t.text_dim) + } + }; + ui.vertical_centered(|ui| { + ui.label( + RichText::new(msg) + .font(FontId::new(13.0, fonts::regular())) + .color(col), + ); + }); + ui.ctx().request_repaint_after( + std::time::Duration::from_millis(300), + ); + } else { + self.cancel_msg = None; + } + } + ui.add_space(20.0); + }); + }); + }); + if let Some(npub) = open_profile { + self.profile = Some(npub); + close = true; + } + close + } + + /// Full-surface contact profile: who they are, history between us, and a + /// block toggle (a nostr-level mute). + fn profile_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + _cb: &dyn PlatformCallbacks, + npub: &str, + ) -> bool { + let t = theme::tokens(); + let (name, hue) = wallet + .nostr_service() + .map(|s| data::contact_title(&s.store, npub)) + .unwrap_or_else(|| (data::short_npub(npub), 0)); + let contact = wallet.nostr_service().and_then(|s| s.store.contact(npub)); + let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false); + let nip05 = contact.as_ref().and_then(|c| c.nip05.clone()); + let history = data::history_with(wallet, npub); + let tex = self.handle_tex(ui.ctx(), wallet, &name); + let htexs: Vec> = history + .iter() + .map(|i| self.handle_tex(ui.ctx(), wallet, &i.title)) + .collect(); + let mut close = false; + let mut do_pay = false; + let mut do_block = false; + let mut open_receipt: Option = None; + egui::CentralPanel::default() + .frame(egui::Frame { + fill: t.bg, + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + 20.0) as i8, + right: (View::get_right_inset() + 20.0) as i8, + top: (View::get_top_inset() + 12.0) as i8, + bottom: (View::get_bottom_inset() + 12.0) as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| { + if Self::overlay_back_header(ui, &t!("goblin.profile.title")) { + close = true; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(8.0); + ui.vertical_centered(|ui| { + w::avatar_any(ui, &name, npub, 72.0, hue, tex.as_ref()); + ui.add_space(12.0); + ui.label( + RichText::new(&name) + .font(FontId::new(22.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(2.0); + let sub = nip05 + .clone() + .map(|n| format!("✓ {}", n)) + .unwrap_or_else(|| data::short_npub(npub)); + ui.label( + RichText::new(sub) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.add_space(18.0); + if !blocked + && w::big_action(ui, &t!("goblin.home.pay"), false).clicked() + { + do_pay = true; + } + ui.add_space(18.0); + w::kicker(ui, &t!("goblin.profile.activity")); + ui.add_space(10.0); + if history.is_empty() { + ui.label( + RichText::new(t!("goblin.profile.no_activity")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } else { + for (item, htex) in history.iter().zip(htexs.iter()) { + let sign = if item.incoming { "+ " } else { "− " }; + let amount = + format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU); + let status_word = if item.canceled { + t!("goblin.activity.canceled").to_string() + } else { + t!("goblin.activity.pending").to_string() + }; + let subtitle = match (&item.note, item.confirmed) { + (Some(n), true) => { + format!("{} · {}", n, View::format_time(item.time)) + } + (Some(n), false) => format!("{} · {}", n, status_word), + (None, true) => View::format_time(item.time), + (None, false) => status_word.clone(), + }; + if w::activity_row( + ui, + &item.title, + &subtitle, + item.hue, + item.npub.as_deref().unwrap_or(""), + &amount, + item.incoming, + item.system, + htex.as_ref(), + ) + .clicked() + { + open_receipt = Some(item.tx_id); + } + } + } + ui.add_space(24.0); + let label = if blocked { + t!("goblin.profile.unblock").to_string() + } else { + format!("{} {}", PROHIBIT, t!("goblin.profile.block")) + }; + if w::big_action_on_card_ink(ui, &label, t.neg).clicked() { + do_block = true; + } + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(if blocked { + t!("goblin.profile.blocked_blurb") + } else { + t!("goblin.profile.block_blurb") + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + }); + ui.add_space(20.0); + }); + }); + }); + if let Some(id) = open_receipt { + self.receipt = Some(id); + close = true; + } + if do_pay { + let mut f = SendFlow::default(); + f.prefill_contact(name.clone(), npub.to_string()); + self.send = Some(f); + close = true; + } + if do_block { + if let Some(s) = wallet.nostr_service() { + let mut c = s.store.contact(npub).unwrap_or(crate::nostr::Contact { + ver: 1, + npub: npub.to_string(), + petname: None, + nip05: nip05.clone(), + nip05_verified_at: None, + relays: vec![], + hue: hue as u8, + unknown: true, + added_at: crate::nostr::unix_time(), + last_paid_at: None, + blocked: false, + }); + c.blocked = !c.blocked; + s.store.save_contact(&c); + } + } + close + } + + /// Friendly day-grouping label for the activity feed. + fn day_label(ts: i64) -> String { + use chrono::{TimeZone, Utc}; + let Some(dt) = Utc.timestamp_opt(ts, 0).single() else { + return t!("goblin.activity.earlier").to_string(); + }; + let today = Utc::now().date_naive(); + let day = dt.date_naive(); + if day == today { + t!("goblin.activity.today").to_string() + } else if (today - day).num_days() == 1 { + t!("goblin.activity.yesterday").to_string() + } else { + dt.format("%b %-d, %Y").to_string() + } + } + + fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.activity.title")) + .font(FontId::new(28.0, fonts::bold())) + .color(theme::tokens().text), + ); + ui.add_space(12.0); + + // Recent contacts strip (payment-app-style row above the feed). + self.peers_strip_ui(ui, wallet, "goblin_peers_activity"); + + // Pending payment requests pinned on top. + if let Some(service) = wallet.nostr_service() { + // An approve that failed (e.g. funds still confirming) flips the send + // phase to FAILED — un-grey the buttons so the user can retry, and + // surface why instead of leaving the card stuck. + if service.send_phase() == crate::nostr::send_phase::FAILED + && !self.approving.is_empty() + { + self.approving.clear(); + self.request_error = service.last_send_error(); + } + let requests = service.store.pending_requests(); + if !requests.is_empty() { + w::section_header(ui, &t!("goblin.activity.requests")); + if let Some(err) = &self.request_error { + ui.add_space(4.0); + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(theme::tokens().neg), + ); + ui.add_space(4.0); + } + for req in requests { + self.request_row_ui(ui, &req, wallet); + } + ui.add_space(8.0); + } + } + + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + let items = activity_items(wallet); + if items.is_empty() { + empty_state( + ui, + &t!("goblin.activity.empty_title"), + &t!("goblin.activity.empty_sub"), + ); + } else { + // Unconfirmed (< min confirmations) pinned on top as Pending. + let pending: Vec<&_> = + items.iter().filter(|i| !i.confirmed && !i.system).collect(); + if !pending.is_empty() { + w::section_header(ui, &t!("goblin.activity.pending_header")); + for item in pending { + self.activity_item_ui(ui, item, wallet, cb); + } + ui.add_space(8.0); + } + // Confirmed, grouped by day (newest first). + let mut last: Option = None; + for item in items.iter().filter(|i| i.confirmed || i.system) { + let label = Self::day_label(item.time); + if last.as_deref() != Some(label.as_str()) { + w::section_header(ui, &label); + last = Some(label); + } + self.activity_item_ui(ui, item, wallet, cb); + } + } + ui.add_space(16.0); + }); + } + + fn activity_item_ui( + &mut self, + ui: &mut egui::Ui, + item: &ActivityItem, + wallet: &Wallet, + _cb: &dyn PlatformCallbacks, + ) { + let sign = if item.incoming { "+ " } else { "− " }; + let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU); + let status_word = if item.canceled { + t!("goblin.activity.canceled").to_string() + } else { + t!("goblin.activity.pending").to_string() + }; + let subtitle = match (&item.note, item.confirmed) { + (Some(note), true) => format!("{} · {}", note, View::format_time(item.time)), + (Some(note), false) => format!("{} · {}", note, status_word), + (None, true) => View::format_time(item.time), + (None, false) => status_word.clone(), + }; + let tex = self.handle_tex(ui.ctx(), wallet, &item.title); + if w::activity_row( + ui, + &item.title, + &subtitle, + item.hue, + item.npub.as_deref().unwrap_or(""), + &amount, + item.incoming, + item.system, + tex.as_ref(), + ) + .clicked() + { + self.receipt = Some(item.tx_id); + } + } + + fn request_row_ui( + &mut self, + ui: &mut egui::Ui, + req: &crate::nostr::PaymentRequest, + wallet: &Wallet, + ) { + let t = theme::tokens(); + let (name, hue) = wallet + .nostr_service() + .map(|s| data::contact_title(&s.store, &req.npub)) + .unwrap_or_else(|| (data::short_npub(&req.npub), 0)); + let tex = self.handle_tex(ui.ctx(), wallet, &name); + w::card(ui, |ui| { + ui.horizontal(|ui| { + w::avatar_any(ui, &name, &req.npub, 40.0, hue, tex.as_ref()); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.label( + RichText::new(t!("goblin.request.title", name => name)) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label( + RichText::new(w::amount_str(req.amount)) + .font(FontId::new(15.0, fonts::mono_semibold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(w::TSU) + .font(FontId::new(13.0, fonts::medium())) + .color(t.surface_text_dim), + ); + }); + }); + }); + if let Some(note) = &req.note { + ui.add_space(6.0); + ui.label( + RichText::new(format!("\u{201C}{}\u{201D}", note)) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if decline_button(ui) { + // Optimistically clear the card, then send the decline as + // a void control message so the requester's side clears + // too. Requests are messages; payments are final. + let mut r = req.clone(); + r.status = crate::nostr::RequestStatus::Declined; + if let Some(s) = wallet.nostr_service() { + s.store.save_request(&r); + } + wallet.task(crate::wallet::types::WalletTask::NostrDeclineRequest( + req.rumor_id.clone(), + )); + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + let already = self.approving.contains(&req.rumor_id); + let working = already + && wallet + .nostr_service() + .map(|s| s.send_phase() == crate::nostr::send_phase::WORKING) + .unwrap_or(false); + if already { + // Paying: show a centered spinner so the tap clearly + // registered (the card clears itself once it's sent). + ui.vertical_centered(|ui| { + ui.add_space(6.0); + View::small_loading_spinner(ui); + ui.add_space(2.0); + ui.label( + RichText::new(t!("goblin.receipt.paying")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_dim), + ); + }); + if working { + ui.ctx().request_repaint(); + } + } else if approve_button(ui) { + // Don't pay on the tap — open the review screen and make + // the user hold-to-accept there, like a send. The actual + // NostrPayRequest is dispatched from approve_review_ui. + self.request_error = None; + self.approve_hold = w::HoldToSend::default(); + self.approve_fee_for = None; + self.approve_review = Some(req.clone()); + } + }, + ); + }); + }); + ui.add_space(10.0); + } + + /// Full-surface review for an incoming payment request: who's asking, how + /// much, the network fee — then hold-to-accept. Paying a request is a spend, + /// so this mirrors the send review's confirm gesture instead of a one-tap + /// accept. Returns true when the screen should close (back, or after the + /// payment is enqueued by the hold). + fn approve_review_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { + let t = theme::tokens(); + let Some(req) = self.approve_review.clone() else { + return true; + }; + let (name, hue) = wallet + .nostr_service() + .map(|s| data::contact_title(&s.store, &req.npub)) + .unwrap_or_else(|| (data::short_npub(&req.npub), 0)); + let tex = self.handle_tex(ui.ctx(), wallet, &name); + // Paying a request spends our balance, so guard against over-balance and + // disable the accept gesture (re-checked each frame). + let spendable = wallet + .get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + let over = req.amount > spendable; + let mut close = false; + egui::CentralPanel::default() + .frame(egui::Frame { + fill: t.bg, + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + 20.0) as i8, + right: (View::get_right_inset() + 20.0) as i8, + top: (View::get_top_inset() + 12.0) as i8, + bottom: (View::get_bottom_inset() + 12.0) as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| { + if Self::overlay_back_header(ui, &t!("goblin.request.review_title")) { + close = true; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.add_space(8.0); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.add_space(8.0); + ui.vertical_centered(|ui| { + w::avatar_any(ui, &name, &req.npub, 40.0, hue, tex.as_ref()); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.request.title", name => &name)) + .font(FontId::new(14.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.add_space(8.0); + let amt = w::amount_str(req.amount); + w::amount_text_centered_ink( + ui, + &amt, + 48.0, + t.surface_text, + t.surface_text_dim, + ); + ui.add_space(8.0); + }); + ui.add_space(16.0); + + w::info_row(ui, &t!("goblin.send.row_from"), &name); + if let Some(note) = &req.note { + if !note.trim().is_empty() { + w::info_row( + ui, + &t!("goblin.send.row_note"), + &format!("\u{201C}{}\u{201D}", note.trim()), + ); + } + } + // Live network fee for paying this request (a spend), + // priced like the send review — one CalculateFee per amount. + if req.amount > 0 && self.approve_fee_for != Some(req.amount) { + self.approve_fee_for = Some(req.amount); + wallet.task(crate::wallet::types::WalletTask::CalculateFee( + req.amount, 0, + )); + } + let fee_val = match wallet.calculated_fee(req.amount) { + Some(fee) => format!("{} {}", w::amount_str(fee), w::TSU), + None => { + ui.ctx().request_repaint_after( + std::time::Duration::from_millis(120), + ); + "…".to_string() + } + }; + w::info_row(ui, &t!("goblin.send.row_network_fee"), &fee_val); + w::info_row( + ui, + &t!("goblin.send.row_privacy"), + &t!("goblin.send.row_privacy_val"), + ); + w::info_row( + ui, + &t!("goblin.send.row_delivery"), + &t!("goblin.send.row_delivery_val"), + ); + ui.add_space(16.0); + + if over { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.send.not_enough")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.neg), + ); + }); + ui.add_space(8.0); + } + ui.add_enabled_ui(!over, |ui| { + if self + .approve_hold + .ui(ui, &t!("goblin.request.hold_to_accept")) + && !over + { + // Guard double-pay + show the spinner back on the + // request card; dispatch the actual payment. + self.approving.insert(req.rumor_id.clone()); + self.request_error = None; + wallet.task(crate::wallet::types::WalletTask::NostrPayRequest( + req.rumor_id.clone(), + )); + close = true; + } + }); + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(if over { + t!("goblin.send.lower_amount") + } else { + t!("goblin.request.hold_accept_hint") + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + }); + ui.add_space(16.0); + }); + }); + }); + close + } + + fn receive_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.receive.title")) + .font(FontId::new(28.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(16.0); + + let handle = wallet + .nostr_service() + .map(|s| { + let identity = s.identity.read(); + identity + .nip05 + .clone() + .map(|n| n.split('@').next().unwrap_or("").to_string()) + .unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub))) + }) + .unwrap_or_else(|| "—".to_string()); + let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default(); + let nprofile = wallet + .nostr_service() + .map(|s| s.nprofile()) + .unwrap_or_else(|| npub.clone()); + + w::card(ui, |ui| { + ui.vertical_centered(|ui| { + // QR of the nostr handle (nostr: URI). + ui.add_space(12.0); + let uri = format!("nostr:{}", nprofile); + w::qr_code(ui, &uri, 220.0); + ui.add_space(14.0); + ui.label( + RichText::new(&handle) + .font(FontId::new(18.0, fonts::bold())) + .color(t.surface_text), + ); + match &self.request_amount { + Some(amt) => { + ui.label( + RichText::new(t!( + "goblin.receive.requesting", + amt => amt, + tsu => w::TSU + )) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + if w::chip(ui, &t!("goblin.receive.clear_request"), false).clicked() { + self.request_amount = None; + } + } + None => { + ui.label( + RichText::new(t!("goblin.receive.share_handle")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + } + } + }); + }); + + ui.add_space(12.0); + // Transient "Copied" feedback on the copy button; a silent copy reads as + // a dead button. + let fresh = |at: std::time::Instant| at.elapsed().as_millis() < 1500; + let copied = matches!(self.receive_copied, Some((1, at)) if fresh(at)); + if self.receive_copied.is_some() { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(200)); + } + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + // Share a friendly "pay me" message carrying the bare npub — the + // public key people pay you on. Never the nprofile or a grin address. + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + let label = t!("goblin.send.share_btn", "icon" => SHARE); + if w::big_action(ui, &label, true).clicked() && !npub.is_empty() { + cb.share_text( + t!("goblin.receive.share_message", "npub" => npub.clone()).to_string(), + ); + } + }, + ); + ui.add_space(10.0); + // Copy the bare npub itself. + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + let label = if copied { + format!("{} {}", CHECK, t!("goblin.receive.copied")) + } else { + format!("{} {}", COPY, t!("goblin.receive.copy_npub")) + }; + if w::big_action(ui, &label, false).clicked() && !npub.is_empty() { + cb.copy_string_to_buffer(npub.clone()); + cb.vibrate_copy(); + self.receive_copied = Some((1, std::time::Instant::now())); + } + }, + ); + }); + + ui.add_space(16.0); + ui.label( + RichText::new(t!("goblin.receive.privacy_note")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + } + + fn me_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + match self.settings_page { + SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb), + SettingsPage::Connections => return self.grim_connections_ui(ui, cb), + SettingsPage::Relays => return self.relays_ui(ui, wallet, cb), + SettingsPage::Nips => return self.nips_ui(ui), + SettingsPage::Pairing => return self.pairing_settings_ui(ui), + SettingsPage::Slatepack => return self.slatepack_ui(ui, wallet, cb), + SettingsPage::Privacy => return self.privacy_ui(ui), + SettingsPage::Advanced => return self.advanced_ui(ui, wallet, cb), + SettingsPage::Main => {} + } + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.settings.title")) + .font(FontId::new(28.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(16.0); + + // Profile card. + let (handle, npub, connected, bare_name, npub_hex) = wallet + .nostr_service() + .map(|s| { + let identity = s.identity.read(); + let bare = identity + .nip05 + .clone() + .map(|n| n.split('@').next().unwrap_or("").to_string()); + let handle = bare + .clone() + .map(|n| n.to_string()) + .unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub))); + ( + handle, + s.npub(), + s.is_connected(), + bare, + hex_of(&identity.npub), + ) + }) + .unwrap_or_else(|| { + ( + t!("goblin.home.anonymous").to_string(), + String::new(), + false, + None, + String::new(), + ) + }); + + let hue = data::hue_of(&npub_hex); + let own_tex = bare_name + .as_deref() + .and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle)); + + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + // Avatar is a generated identicon (gradient + initial) — Goblin has + // no uploaded profile pictures. + w::avatar_any(ui, &handle, &npub_hex, 56.0, hue, own_tex.as_ref()); + ui.add_space(14.0); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + ui.label( + RichText::new(&handle) + .font(FontId::new(17.0, fonts::bold())) + .color(t.surface_text), + ); + // A claimed/verified name gets the little check. + if bare_name.is_some() { + ui.label( + RichText::new(crate::gui::icons::SEAL_CHECK) + .font(FontId::new(15.0, fonts::regular())) + .color(t.pos), + ); + } + }); + // Mixnet status (fast) in place of the redundant second npub line. + let mixnet = if crate::nym::is_ready() { + t!("goblin.home.connected_nym") + } else { + t!("goblin.home.connecting_nym") + }; + ui.label( + RichText::new(mixnet) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + // nostr relay status — the slower step (a relay reached over Nym). + let nostr = if connected { + t!("goblin.settings.connected_nostr") + } else { + t!("goblin.settings.connecting_relays") + }; + ui.label( + RichText::new(nostr) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if !crate::nym::is_ready() || !connected { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(600)); + } + }); + }); + }); + + ui.add_space(16.0); + // Mark the scroll boundary: rows clipping under the pinned profile + // card otherwise read as sliced glyphs on an invisible edge. + let line_y = ui.cursor().min.y; + ui.painter().hline( + ui.max_rect().x_range(), + line_y, + eframe::epaint::Stroke::new(1.0, t.line), + ); + ui.add_space(6.0); + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + // Identity: username, picture, keys — first because it is the + // face of the wallet. + w::kicker(ui, &t!("goblin.settings.identity")); + ui.add_space(8.0); + if self.claim.is_none() { + self.claim = Some(ClaimState::default()); + } + self.claim_ui(ui, wallet, cb); + ui.add_space(8.0); + w::card(ui, |ui| { + if !npub.is_empty() { + if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) { + cb.copy_string_to_buffer(npub.clone()); + cb.vibrate_copy(); + self.copy_flash = Some(std::time::Instant::now()); + } + // One encrypted backup FILE (key + username + history sealed + // together) — replaces the old copy-nsec / copy-JSON split. + if settings_row_btn( + ui, + &t!("goblin.settings.backup_file"), + crate::gui::icons::DOWNLOAD_SIMPLE, + ) && self.backup.is_none() + { + self.backup = Some(BackupState::default()); + } + if settings_row_danger( + ui, + &t!("goblin.settings.rotate_key"), + crate::gui::icons::ARROWS_CLOCKWISE, + ) && self.rotate.is_none() + { + self.rotate = Some(RotateState::default()); + } + if settings_row_btn( + ui, + &t!("goblin.settings.import_identity"), + crate::gui::icons::KEY, + ) && self.import_nsec.is_none() + { + self.import_nsec = Some(ImportState::default()); + } + // Federation: which name authority (server) registers and + // verifies names. Shows the current host on the right. + let authority = wallet + .nostr_service() + .map(|s| s.config.read().home_domain()) + .unwrap_or_default(); + if settings_row_nav(ui, &t!("goblin.settings.name_authority"), &authority) + && self.name_authority.is_none() + { + let cur = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server()) + .unwrap_or_default(); + self.name_authority = Some(NameAuthorityState { + input: cur, + error: None, + }); + } + } + }); + // Transient confirmation that the copy landed — pairs with the + // haptic tick so the tap feels acknowledged. + if let Some(at) = self.copy_flash { + if at.elapsed().as_secs_f32() < 1.5 { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(format!( + "{} {}", + crate::gui::icons::CHECK, + t!("goblin.receipt.copied") + )) + .font(FontId::new(13.0, fonts::medium())) + .color(t.pos), + ); + }); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(120)); + } else { + self.copy_flash = None; + } + } + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.settings.backup_note")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + if self.backup.is_some() { + ui.add_space(8.0); + self.backup_ui(ui, wallet, cb); + } + if self.name_authority.is_some() { + ui.add_space(8.0); + self.name_authority_ui(ui, wallet, cb); + } + if self.rotate.is_some() { + ui.add_space(8.0); + self.rotate_ui(ui, wallet, cb); + } + if self.import_nsec.is_some() { + ui.add_space(8.0); + self.import_nsec_ui(ui, wallet, cb); + } + + ui.add_space(16.0); + let mut open_relays = false; + let mut open_node = false; + let mut open_slatepack = false; + settings_group(ui, &t!("goblin.settings.wallet"), |ui| { + settings_row(ui, &t!("goblin.settings.display_unit"), "ツ (grin)"); + if settings_row_nav(ui, &t!("goblin.settings.relays"), &relay_summary(wallet)) { + open_relays = true; + } + if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) { + open_node = true; + } + // GRIM's native by-hand slatepack exchange, for when a payment + // can't go through a username. + if settings_row_nav( + ui, + &t!("goblin.settings.slatepacks"), + &t!("goblin.settings.slatepacks_value"), + ) { + open_slatepack = true; + } + }); + if open_slatepack { + self.slatepack = SlatepackManual::default(); + self.settings_page = SettingsPage::Slatepack; + } + if open_relays { + self.relay_edit = wallet + .nostr_service() + .map(|s| s.config.read().relays()) + .unwrap_or_default(); + self.relay_input.clear(); + self.settings_page = SettingsPage::Relays; + } + if open_node { + self.node_url_input.clear(); + self.node_secret_input.clear(); + self.settings_page = SettingsPage::Node; + } + + ui.add_space(16.0); + let mut open_pairing = false; + let mut open_privacy = false; + settings_group(ui, &t!("goblin.settings.privacy"), |ui| { + // Messages, names, price and avatars ride the mixnet; the grin + // node connects directly. Flagged in the privacy color so it + // still reads as the headline guarantee — tap for the breakdown. + if settings_row_nav_ink( + ui, + &t!("goblin.settings.mixnet_routing"), + &t!("goblin.settings.messages_lookups"), + theme::tokens().neg, + ) { + open_privacy = true; + } + // Tap to cycle the incoming-payment accept policy. + if settings_row_btn( + ui, + &t!("goblin.settings.auto_accept"), + &accept_policy_label(wallet), + ) { + cycle_accept_policy(wallet); + } + // Amount pairing: what the ≈ preview is shown against. + if settings_row_nav( + ui, + &t!("goblin.settings.pairing"), + crate::AppConfig::pairing().label(), + ) { + open_pairing = true; + } + }); + if open_pairing { + self.settings_page = SettingsPage::Pairing; + } + if open_privacy { + self.settings_page = SettingsPage::Privacy; + } + + ui.add_space(16.0); + settings_group(ui, &t!("goblin.settings.requests"), |ui| { + let allow = wallet + .nostr_service() + .map(|s| s.config.read().allow_incoming_requests()) + .unwrap_or(true); + if let Some(v) = settings_row_toggle( + ui, + &t!("goblin.settings.incoming_requests"), + &t!("goblin.settings.incoming_requests_sub"), + allow, + ) { + if let Some(s) = wallet.nostr_service() { + s.config.write().set_allow_incoming_requests(v); + } + // Advertise the change so requesters see it before asking. + wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile); + } + }); + + ui.add_space(16.0); + w::kicker(ui, &t!("goblin.settings.appearance")); + ui.add_space(8.0); + w::card(ui, |ui| { + let theme_label = match crate::AppConfig::theme() { + crate::gui::theme::ThemeKind::Light => t!("goblin.settings.theme_light"), + crate::gui::theme::ThemeKind::Dark => t!("goblin.settings.theme_dark"), + crate::gui::theme::ThemeKind::Yellow => t!("goblin.settings.theme_yellow"), + }; + if settings_row_btn(ui, &t!("goblin.settings.theme"), &theme_label) { + cycle_theme(ui.ctx()); + } + }); + + ui.add_space(16.0); + w::kicker(ui, &t!("goblin.settings.archive")); + ui.add_space(8.0); + w::card(ui, |ui| { + if settings_row_btn(ui, &t!("goblin.settings.export_archive"), COPY) { + if let Some(s) = wallet.nostr_service() { + let json = s.store.export_json(&s.npub()); + cb.copy_string_to_buffer(json); + cb.vibrate_copy(); + } + } + if settings_row_btn( + ui, + &t!("goblin.settings.wipe_history"), + crate::gui::icons::X, + ) { + if let Some(s) = wallet.nostr_service() { + s.store.wipe_archive(); + } + } + }); + + ui.add_space(16.0); + settings_group(ui, &t!("goblin.settings.about"), |ui| { + if settings_row_nav( + ui, + &t!("goblin.settings.goblin"), + &t!("goblin.settings.build", build => crate::BUILD), + ) { + open_url(ui, "https://github.com/2ro/goblin/releases"); + } + settings_row( + ui, + &t!("goblin.settings.network"), + &t!("goblin.settings.network_value"), + ); + }); + + ui.add_space(16.0); + let mut open_nips = false; + settings_group(ui, &t!("goblin.settings.third_party"), |ui| { + if settings_row_nav(ui, &t!("goblin.settings.grim"), crate::VERSION) { + open_url(ui, "https://github.com/ardocrat/grim"); + } + if settings_row_nav(ui, &t!("goblin.settings.grin_node"), "5.4.0") { + open_url(ui, "https://github.com/mimblewimble/grin"); + } + if settings_row_nav(ui, "nostr-sdk", "0.44") { + open_url(ui, "https://github.com/rust-nostr/nostr"); + } + if settings_row_nav(ui, "Nym mixnet", "sdk 1.21") { + open_url(ui, "https://nym.com"); + } + if settings_row_nav(ui, "egui", "0.33") { + open_url(ui, "https://github.com/emilk/egui"); + } + if settings_row_nav(ui, "NIPs", "05 · 17 · 44 · 49 · 59 · 98") { + open_nips = true; + } + }); + if open_nips { + self.settings_page = SettingsPage::Nips; + } + + // Wallet management lives at the foot of Settings: a neutral + // switch, the red lock, then the advanced (recovery) tools — + // each its own outlined action, so the destructive lock reads + // apart from the rest. + ui.add_space(24.0); + let dim = theme::tokens().surface_text_dim; + if w::outlined_icon_action( + ui, + crate::gui::icons::USER_SWITCH, + &t!("goblin.settings.switch_wallet"), + dim, + ) + .clicked() + { + self.settings_page = SettingsPage::Main; + self.switch_requested = true; + } + ui.add_space(10.0); + if w::outlined_icon_action( + ui, + crate::gui::icons::LOCK, + &t!("goblin.settings.lock_wallet"), + theme::tokens().neg, + ) + .clicked() + { + wallet.close(); + } + ui.add_space(10.0); + if w::outlined_icon_action( + ui, + crate::gui::icons::WRENCH, + &t!("goblin.settings.advanced"), + dim, + ) + .clicked() + { + self.advanced = AdvancedState::default(); + self.settings_page = SettingsPage::Advanced; + } + ui.add_space(20.0); + }); + } + + /// Back header for Settings sub-pages; returns true when back is tapped. + fn sub_header(&mut self, ui: &mut egui::Ui, title: &str) -> bool { + let t = theme::tokens(); + let mut back = false; + ui.add_space(8.0); + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + crate::gui::icons::ARROW_LEFT, + FontId::new(16.0, fonts::regular()), + theme::ink_for(t.surface2), + ); + back = resp.clicked(); + ui.add_space(12.0); + ui.label( + RichText::new(title) + .font(FontId::new(24.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(16.0); + back + } + + /// Node connection editor: pick integrated/external, add or remove nodes. + fn pairing_settings_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.pairing.title")) { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new(t!("goblin.pairing.intro")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(12.0); + let current = crate::AppConfig::pairing(); + settings_group(ui, &t!("goblin.pairing.pair_with"), |ui| { + for p in crate::settings::Pairing::ALL { + let active = p == current; + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(p.label()) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if active { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } + }); + }); + ui.add_space(10.0); + if !active && row.response.interact(Sense::click()).clicked() { + crate::AppConfig::set_pairing(p); + } + } + }); + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.pairing.rates_note")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(16.0); + }); + } + + /// Network-privacy breakdown: what rides the Nym mixnet versus what connects + /// directly. Honest by design — no claim that node traffic is mixed, and no + /// toggle to route it (chain sync is heavy and not tied to your identity). + fn privacy_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.privacy.title")) { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new(t!("goblin.privacy.intro")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(16.0); + let mixnet = [ + ( + t!("goblin.privacy.payments"), + t!("goblin.privacy.payments_blurb"), + ), + ( + t!("goblin.privacy.usernames"), + t!("goblin.privacy.usernames_blurb"), + ), + ( + t!("goblin.privacy.price_avatars"), + t!("goblin.privacy.price_avatars_blurb"), + ), + ]; + settings_group(ui, &t!("goblin.privacy.over_mixnet"), |ui| { + for (title, blurb) in &mixnet { + privacy_line(ui, t.neg, title, blurb); + } + }); + ui.add_space(16.0); + settings_group(ui, &t!("goblin.privacy.direct_connection"), |ui| { + privacy_line( + ui, + t.surface_text_mute, + &t!("goblin.privacy.grin_node"), + &t!("goblin.privacy.grin_node_blurb"), + ); + }); + ui.add_space(16.0); + }); + } + + /// Advanced (wallet-recovery) page — GRIM's low-level tools surfaced in the + /// goblin style: repair, restore-from-seed, reveal the recovery phrase, and + /// delete. The two destructive actions arm a tap-twice confirm. + fn advanced_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + use crate::wallet::types::ConnectionMethod; + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.advanced.title")) { + self.advanced = AdvancedState::default(); + self.settings_page = SettingsPage::Main; + return; + } + // Repair needs a synced node; mirror GRIM's availability check. + let integrated = wallet.get_current_connection() == ConnectionMethod::Integrated; + let integrated_ready = + crate::node::Node::get_sync_status() == Some(grin_chain::SyncStatus::NoSync); + let repair_unavailable = wallet.sync_error() || (integrated && !integrated_ready); + let repairing = wallet.is_repairing(); + let progress = wallet.repairing_progress(); + let mut leave = false; + let mut open_node = false; + { + let adv = &mut self.advanced; + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new(t!("goblin.advanced.intro")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(16.0); + + // Run your own node (the internal node). Opens the node-connection + // page, where you pick the integrated node or an external one. + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + advanced_head(ui, &t!("goblin.node.integrated"), t.surface_text); + advanced_desc(ui, &t!("goblin.advanced.own_node_desc")); + ui.add_space(10.0); + if wallet.get_current_connection() == ConnectionMethod::Integrated { + ui.label( + RichText::new(format!( + "{} {}", + crate::gui::icons::CHECK, + t!("goblin.advanced.own_node_active") + )) + .font(FontId::new(13.0, fonts::medium())) + .color(t.pos), + ); + ui.add_space(10.0); + } + if w::big_action_on_card(ui, &t!("goblin.advanced.manage_node")).clicked() { + open_node = true; + } + }); + ui.add_space(12.0); + + // Repair. + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + advanced_head(ui, &t!("goblin.advanced.repair"), t.surface_text); + advanced_desc(ui, &t!("goblin.advanced.repair_desc")); + ui.add_space(10.0); + if repairing { + ui.label( + RichText::new( + t!("goblin.advanced.repairing", pct => progress.to_string()), + ) + .font(FontId::new(13.0, fonts::medium())) + .color(t.accent), + ); + } else if repair_unavailable { + ui.label( + RichText::new(t!("goblin.advanced.repair_unavailable")) + .font(FontId::new(13.0, fonts::medium())) + .color(t.neg), + ); + } else if adv.confirm_repair { + // Repair re-scans the chain — it can take a few minutes. + // Warn + confirm in the accent (yellow) before starting. + ui.label( + RichText::new(t!("goblin.advanced.repair_confirm_note")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card_ink( + ui, + &t!("goblin.advanced.repair_confirm"), + t.accent, + ) + .clicked() + { + adv.confirm_repair = false; + wallet.repair(); + } + } else if w::big_action_on_card(ui, &t!("goblin.advanced.repair")).clicked() + { + adv.confirm_repair = true; + } + }); + ui.add_space(12.0); + + // Restore (rebuild local data from the seed). + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + advanced_head(ui, &t!("goblin.advanced.restore"), t.surface_text); + advanced_desc(ui, &t!("goblin.advanced.restore_desc")); + ui.add_space(10.0); + if adv.confirm_restore { + ui.label( + RichText::new(t!("goblin.advanced.restore_confirm_note")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card_ink( + ui, + &t!("goblin.advanced.restore_confirm"), + t.neg, + ) + .clicked() + { + wallet.delete_db(); + leave = true; + } + } else if w::big_action_on_card(ui, &t!("goblin.advanced.restore")) + .clicked() + { + adv.confirm_restore = true; + } + }); + ui.add_space(12.0); + + // Recovery phrase (the grin seed words). + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + advanced_head(ui, &t!("goblin.advanced.show_phrase"), t.surface_text); + advanced_desc(ui, &t!("goblin.advanced.phrase_desc")); + ui.add_space(10.0); + if let Some(words) = adv.revealed.clone() { + w::field_well(ui, |ui| { + ui.label( + RichText::new(words) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + }); + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() { + adv.revealed = None; + adv.reveal_pass.clear(); + } + } else { + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("advanced_reveal_pass")) + .focus(false) + .hint_text(t!("goblin.advanced.password")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut adv.reveal_pass, cb); + }); + if adv.wrong_pass { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.advanced.wrong_password")) + .font(FontId::new(13.0, fonts::medium())) + .color(t.neg), + ); + } + ui.add_space(10.0); + ui.add_enabled_ui(!adv.reveal_pass.is_empty(), |ui| { + if w::big_action_on_card(ui, &t!("goblin.advanced.reveal")) + .clicked() + { + match wallet.get_recovery(adv.reveal_pass.clone()) { + Ok(phrase) => { + adv.revealed = Some(phrase.to_string()); + adv.wrong_pass = false; + adv.reveal_pass.clear(); + } + Err(_) => { + adv.wrong_pass = true; + } + } + } + }); + } + }); + ui.add_space(12.0); + + // Delete. + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + advanced_head(ui, &t!("goblin.advanced.delete"), t.neg); + advanced_desc(ui, &t!("goblin.advanced.delete_desc")); + ui.add_space(10.0); + if adv.confirm_delete { + if w::big_action_on_card_ink( + ui, + &t!("goblin.advanced.delete_confirm"), + t.neg, + ) + .clicked() + { + wallet.delete_wallet(); + leave = true; + } + } else if w::big_action_on_card_ink( + ui, + &t!("goblin.advanced.delete"), + t.neg, + ) + .clicked() + { + adv.confirm_delete = true; + } + }); + ui.add_space(20.0); + }); + } + if leave { + self.advanced = AdvancedState::default(); + self.settings_page = SettingsPage::Main; + } + if open_node { + // Advanced → "Manage node connection" opens GRIM's native connections UI. + self.settings_page = SettingsPage::Connections; + } + } + + /// GRIM's native node-connections screen, embedded under a Goblin back header. + fn grim_connections_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + use crate::gui::views::types::ContentContainer; + if self.sub_header(ui, &t!("goblin.node.title")) { + self.settings_page = SettingsPage::Advanced; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + self.grim_connections.ui(ui, cb); + }); + } + + fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + use crate::wallet::types::ConnectionMethod; + use crate::wallet::{ConnectionsConfig, ExternalConnection}; + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.node.title")) { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + let live = wallet.get_current_connection(); + let saved = wallet.get_config().connection(); + settings_group(ui, &t!("goblin.node.connection"), |ui| { + // Integrated node (run your own) sits at the top of the picker. + { + let active = matches!(&saved, ConnectionMethod::Integrated); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(t!("goblin.node.integrated")) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if active { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } + }); + }); + ui.add_space(10.0); + if !active && row.response.interact(Sense::click()).clicked() { + wallet.update_connection(&ConnectionMethod::Integrated); + } + } + for conn in ConnectionsConfig::ext_conn_list() { + let active = + matches!(&saved, ConnectionMethod::External(id, _) if *id == conn.id); + let label = conn.url.replace("https://", "").replace("http://", ""); + let mut removed = false; + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(&label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if active { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } else { + let x = ui.label( + RichText::new(crate::gui::icons::X) + .font(FontId::new(15.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if x.interact(Sense::click()).clicked() { + ConnectionsConfig::remove_ext_conn(conn.id); + removed = true; + } + } + }); + }); + ui.add_space(10.0); + if !removed && !active && row.response.interact(Sense::click()).clicked() { + wallet.update_connection(&ConnectionMethod::External( + conn.id, + conn.url.clone(), + )); + } + } + }); + if saved != live { + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.node.applies_after")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } + + ui.add_space(16.0); + settings_group(ui, &t!("goblin.node.add_external"), |ui| { + TextEdit::new(egui::Id::from("set_node_url")) + .focus(false) + .hint_text("https://node.example.com:3413") + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.node_url_input, cb); + ui.add_space(8.0); + TextEdit::new(egui::Id::from("set_node_secret")) + .focus(false) + .hint_text(t!("goblin.node.api_secret_hint")) + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.node_secret_input, cb); + }); + ui.add_space(10.0); + let url = self.node_url_input.trim().to_string(); + let valid = url.starts_with("http://") || url.starts_with("https://"); + if w::big_action(ui, &t!("goblin.node.add_node"), false).clicked() && valid { + let secret = { + let s = self.node_secret_input.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }; + let conn = ExternalConnection::new(url, None, secret); + wallet + .update_connection(&ConnectionMethod::External(conn.id, conn.url.clone())); + ConnectionsConfig::add_ext_conn(conn); + self.node_url_input.clear(); + self.node_secret_input.clear(); + } + ui.add_space(16.0); + }); + } + + /// Relay list editor; saving restarts the nostr service live. + fn relays_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.relays.title")) { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new(t!("goblin.relays.intro")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(14.0); + settings_group(ui, &t!("goblin.relays.your_relays"), |ui| { + let mut remove: Option = None; + let many = self.relay_edit.len() > 1; + for (i, relay) in self.relay_edit.iter().enumerate() { + ui.horizontal(|ui| { + ui.label( + RichText::new(relay) + .font(FontId::new(14.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if many { + let x = ui.label( + RichText::new(crate::gui::icons::X) + .font(FontId::new(15.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if x.interact(Sense::click()).clicked() { + remove = Some(i); + } + } + }); + }); + ui.add_space(10.0); + } + if let Some(i) = remove { + self.relay_edit.remove(i); + } + }); + + ui.add_space(16.0); + settings_group(ui, &t!("goblin.relays.add_relay"), |ui| { + TextEdit::new(egui::Id::from("set_relay")) + .focus(false) + .hint_text("wss://relay.example.com") + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.relay_input, cb); + }); + ui.add_space(10.0); + let relay = self.relay_input.trim().to_string(); + let valid = relay.starts_with("wss://") || relay.starts_with("ws://"); + if w::big_action_on_card(ui, &t!("goblin.relays.add_relay_btn")).clicked() + && valid && !self.relay_edit.contains(&relay) + { + self.relay_edit.push(relay); + self.relay_input.clear(); + } + ui.add_space(10.0); + if w::big_action(ui, &t!("goblin.relays.save_reconnect"), false).clicked() { + if let Some(s) = wallet.nostr_service() { + { + let mut c = s.config.write(); + c.set_relays(self.relay_edit.clone()); + c.save(); + } + s.restart(wallet.clone()); + } + self.settings_page = SettingsPage::Main; + } + ui.add_space(16.0); + }); + } + + /// Manual slatepack exchange — GRIM's native by-hand flow, exposed as an + /// advanced fallback for when a payment can't ride a @username. + fn slatepack_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.settings.slatepacks")) { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new(t!("goblin.settings.sp_intro")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(14.0); + + // Receive / continue: paste a slatepack, let the wallet route it. + let mut do_process = false; + settings_group(ui, &t!("goblin.settings.sp_receive_group"), |ui| { + ui.label( + RichText::new(t!("goblin.settings.sp_receive_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(8.0); + TextEdit::new(egui::Id::from("sp_paste")) + .focus(false) + .paste() + .hint_text("BEGINSLATEPACK. … ENDSLATEPACK.") + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.slatepack.paste, cb); + }); + ui.add_space(10.0); + if w::big_action(ui, &t!("goblin.settings.sp_process"), false).clicked() { + do_process = true; + } + if do_process { + let text = self.slatepack.paste.trim().to_string(); + if text.is_empty() { + self.slatepack.error = + Some(t!("goblin.settings.sp_paste_first").to_string()); + self.slatepack.status = None; + } else { + use crate::wallet::types::ManualSlatepackOutcome as Out; + match wallet.manual_process_slatepack(&text) { + Ok(Out::Response(reply)) => { + self.slatepack.result = reply; + self.slatepack.status = + Some(t!("goblin.settings.sp_reply_ready").to_string()); + self.slatepack.error = None; + self.slatepack.paste.clear(); + } + Ok(Out::Finalizing) => { + self.slatepack.result.clear(); + self.slatepack.status = + Some(t!("goblin.settings.sp_finalizing").to_string()); + self.slatepack.error = None; + self.slatepack.paste.clear(); + } + Err(e) => { + self.slatepack.error = Some(e.to_string()); + self.slatepack.status = None; + } + } + } + } + + // Send: create a slatepack to hand over out-of-band. + ui.add_space(16.0); + let mut do_send = false; + settings_group(ui, &t!("goblin.settings.sp_create_group"), |ui| { + ui.label( + RichText::new(t!("goblin.settings.sp_create_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(8.0); + TextEdit::new(egui::Id::from("sp_amount")) + .focus(false) + .numeric() + .hint_text(t!("goblin.settings.sp_amount_hint")) + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.slatepack.amount, cb); + ui.add_space(8.0); + TextEdit::new(egui::Id::from("sp_addr")) + .focus(false) + .hint_text(t!("goblin.settings.sp_addr_hint")) + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.slatepack.address, cb); + }); + ui.add_space(10.0); + if w::big_action(ui, &t!("goblin.settings.sp_create"), false).clicked() { + do_send = true; + } + if do_send { + match grin_core::core::amount_from_hr_string(self.slatepack.amount.trim()) { + Ok(a) if a > 0 => { + let s = self.slatepack.address.trim(); + let dest = if s.is_empty() { + None + } else { + Some(s.to_string()) + }; + match wallet.manual_send_slatepack(a, dest) { + Ok(text) => { + self.slatepack.result = text; + self.slatepack.status = + Some(t!("goblin.settings.sp_ready").to_string()); + self.slatepack.error = None; + } + Err(e) => { + self.slatepack.error = Some(e.to_string()); + self.slatepack.status = None; + } + } + } + _ => { + self.slatepack.error = + Some(t!("goblin.settings.sp_amount_gt_zero").to_string()); + self.slatepack.status = None; + } + } + } + + // Status, error, and the produced slatepack (copyable). + if let Some(err) = self.slatepack.error.clone() { + ui.add_space(10.0); + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } + if let Some(status) = self.slatepack.status.clone() { + ui.add_space(10.0); + ui.label( + RichText::new(status) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } + let result = self.slatepack.result.clone(); + if !result.is_empty() { + ui.add_space(14.0); + settings_group(ui, &t!("goblin.settings.sp_to_send"), |ui| { + let preview: String = result.chars().take(120).collect(); + let preview = if result.chars().count() > 120 { + format!("{preview}…") + } else { + preview + }; + ui.label( + RichText::new(preview) + .font(FontId::new(12.0, fonts::mono())) + .color(t.surface_text_dim), + ); + }); + ui.add_space(10.0); + if w::big_action(ui, &t!("goblin.settings.sp_copy"), false).clicked() { + cb.copy_string_to_buffer(result); + cb.vibrate_copy(); + } + } + ui.add_space(16.0); + }); + } + + /// What-is-nostr explainer and tappable NIP reference list. + fn nips_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.nips.title")) { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new(t!("goblin.nips.intro1")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.nips.intro2")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(16.0); + let nips = [ + ( + "05", + t!("goblin.nips.n05_title"), + t!("goblin.nips.n05_blurb"), + ), + ( + "17", + t!("goblin.nips.n17_title"), + t!("goblin.nips.n17_blurb"), + ), + ( + "44", + t!("goblin.nips.n44_title"), + t!("goblin.nips.n44_blurb"), + ), + ( + "49", + t!("goblin.nips.n49_title"), + t!("goblin.nips.n49_blurb"), + ), + ( + "59", + t!("goblin.nips.n59_title"), + t!("goblin.nips.n59_blurb"), + ), + ( + "98", + t!("goblin.nips.n98_title"), + t!("goblin.nips.n98_blurb"), + ), + ]; + for (num, title, blurb) in &nips { + let resp = ui.scope(|ui| { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(format!("NIP-{} · {}", num, title)) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(2.0); + ui.label( + RichText::new(blurb.as_ref()) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + if resp.response.interact(Sense::click()).clicked() { + open_url( + ui, + &format!( + "https://github.com/nostr-protocol/nips/blob/master/{}.md", + num + ), + ); + } + ui.add_space(8.0); + } + ui.add_space(16.0); + }); + } + + /// Inline key-rotation flow: warning → typed RESET + password → result. + fn rotate_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + let rotate = self.rotate.as_mut().unwrap(); + // Poll the worker result. + if rotate.stage == 3 { + if let Some(res) = rotate.result.lock().unwrap().take() { + match res { + Ok(npub) => { + rotate.new_npub = npub; + rotate.stage = 4; + } + Err(e) => { + rotate.error = e; + rotate.stage = 5; + } + } + } + } + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match rotate.stage { + 1 => { + ui.label( + RichText::new(t!("goblin.settings.rotate_key")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(6.0); + for line in [ + t!("goblin.settings.rotate_line1"), + t!("goblin.settings.rotate_line2"), + t!("goblin.settings.rotate_line3"), + t!("goblin.settings.rotate_line4"), + t!("goblin.settings.rotate_line5"), + ] { + ui.label( + RichText::new(line) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(4.0); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.cancel")) + .clicked() + { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action(ui, &t!("goblin.settings.continue"), false) + .clicked() + { + rotate.stage = 2; + } + }, + ); + }); + } + 2 => { + ui.label( + RichText::new(t!("goblin.settings.final_confirmation")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.settings.rotate_confirm_blurb")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("rotate_reset")) + .focus(false) + .hint_text(t!("goblin.settings.type_reset")) + .text_color(t.surface_text) + .body() + .ui(ui, &mut rotate.reset_input, cb); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("rotate_pass")) + .focus(false) + .hint_text(t!("goblin.settings.wallet_password")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut rotate.password, cb); + }); + ui.add_space(10.0); + let armed = rotate.reset_input.trim() == "RESET" && !rotate.password.is_empty(); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.cancel")) + .clicked() + { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action( + ui, + &t!("goblin.settings.rotate_key_btn"), + false, + ) + .clicked() + { + rotate.stage = 3; + let slot = rotate.result.clone(); + let password = std::mem::take(&mut rotate.password); + rotate.reset_input.clear(); + let wallet = wallet.clone(); + std::thread::spawn(move || { + let res = wallet.rotate_nostr_identity(password); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + 3 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.settings.rotating_key")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 4 => { + ui.label( + RichText::new(t!("goblin.settings.key_rotated")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.pos), + ); + ui.add_space(4.0); + let npub = &rotate.new_npub; + let short = if npub.len() > 18 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else { + npub.clone() + }; + ui.label( + RichText::new(t!("goblin.settings.new_npub", npub => short)) + .font(FontId::new(13.0, fonts::mono())) + .color(t.surface_text_dim), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.settings.backup_new_key")) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.settings.copy_new_nsec")).clicked() { + if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { + cb.copy_string_to_buffer(nsec); + cb.vibrate_copy(); + } + } + ui.add_space(8.0); + if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new(t!("goblin.settings.rotation_failed")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&rotate.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() { + close = true; + } + } + } + }); + if close { + self.rotate = None; + } + } + + /// Inline nsec-import flow: replaces the identity with an imported key. + /// Inline "change name authority" editor: set the NIP-05 server that registers + /// and verifies names. Lets a user on one instance pay names on another. + fn name_authority_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + let t = theme::tokens(); + let na = self.name_authority.as_mut().unwrap(); + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(t!("goblin.settings.name_authority_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.settings.name_authority_blurb")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("name_authority_input")) + .focus(false) + .hint_text("https://goblin.st") + .text_color(t.surface_text) + .body() + .ui(ui, &mut na.input, cb); + }); + if let Some(err) = &na.error { + ui.add_space(6.0); + ui.label( + RichText::new(err) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + let third = (ui.available_width() - 20.0) / 3.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(third, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.cancel")).clicked() { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(third, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.reset")).clicked() { + if let Some(s) = wallet.nostr_service() { + s.config.write().set_nip05_server(None); + crate::nostr::nip05::set_home_domain( + &s.config.read().home_domain(), + ); + } + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(third, 44.0), + )), + |ui| { + if w::big_action(ui, &t!("goblin.settings.save"), false).clicked() { + let url = na.input.trim().to_string(); + if !url.starts_with("https://") && !url.starts_with("http://") { + na.error = + Some(t!("goblin.settings.name_authority_invalid").to_string()); + } else if let Some(s) = wallet.nostr_service() { + s.config.write().set_nip05_server(Some(url)); + crate::nostr::nip05::set_home_domain( + &s.config.read().home_domain(), + ); + close = true; + } + } + }, + ); + }); + }); + if close { + self.name_authority = None; + } + } + + /// Inline "back up identity to a file" flow: ask for the wallet password, + /// seal the identity, and write a GOBLIN-*.backup file via the native picker. + fn backup_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + let bk = self.backup.as_mut().unwrap(); + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + if bk.done { + ui.label( + RichText::new(t!("goblin.settings.backup_saved")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.pos), + ); + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.settings.backup_saved_sub")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() { + close = true; + } + return; + } + ui.label( + RichText::new(t!("goblin.settings.backup_file_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.settings.backup_file_blurb")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("backup_pass")) + .focus(false) + .hint_text(t!("goblin.settings.wallet_password")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut bk.password, cb); + }); + if let Some(err) = &bk.error { + ui.add_space(6.0); + ui.label( + RichText::new(err) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.cancel")).clicked() { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(!bk.password.is_empty(), |ui| { + if w::big_action(ui, &t!("goblin.settings.create_backup"), false) + .clicked() + { + match wallet.create_nostr_backup(&bk.password) { + Ok(envelope) => { + let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M"); + let fname = format!("GOBLIN-{stamp}.backup"); + match cb.save_file(fname, envelope.into_bytes()) { + Ok(()) => { + bk.done = true; + bk.error = None; + bk.password.clear(); + } + Err(_) => { + bk.error = Some( + t!("goblin.settings.backup_write_failed") + .to_string(), + ); + } + } + } + Err(e) => bk.error = Some(e), + } + } + }); + }, + ); + }); + }); + if close { + self.backup = None; + } + } + + fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + let import = self.import_nsec.as_mut().unwrap(); + if import.stage == 3 { + if let Some(res) = import.result.lock().unwrap().take() { + match res { + Ok(npub) => { + import.new_npub = npub; + import.stage = 4; + } + Err(e) => { + import.error = e; + import.stage = 5; + } + } + } + } + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match import.stage { + 1 => { + ui.label( + RichText::new(t!("goblin.settings.import_identity_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.settings.import_blurb")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + // Native ".backup file" picker. Desktop returns the path now; + // Android returns it asynchronously (poll picked_file()). + if import.picking { + if let Some(path) = cb.picked_file() { + import.picking = false; + if !path.is_empty() { + match std::fs::read_to_string(&path) { + Ok(contents) => import.nsec = contents.trim().to_string(), + Err(_) => { + import.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + } else { + ui.ctx().request_repaint(); + } + } + if w::big_action_on_card(ui, &t!("goblin.settings.choose_backup_file")) + .clicked() + { + import.error.clear(); + match cb.pick_file() { + Some(path) if !path.is_empty() => { + match std::fs::read_to_string(&path) { + Ok(contents) => import.nsec = contents.trim().to_string(), + Err(_) => { + import.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + // Empty string = Android async pick in flight. + Some(_) => import.picking = true, + None => {} + } + } + if !import.error.is_empty() && import.stage == 1 { + ui.add_space(6.0); + ui.label( + RichText::new(&import.error) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("import_nsec")) + .focus(false) + .hint_text(t!("goblin.settings.import_nsec_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut import.nsec, cb); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("import_pass")) + .focus(false) + .hint_text(t!("goblin.settings.wallet_password")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut import.password, cb); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("import_backup_pass")) + .focus(false) + .hint_text(t!("goblin.settings.backup_password_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut import.backup_password, cb); + }); + ui.add_space(10.0); + let pasted = import.nsec.trim(); + let armed = (pasted.starts_with("nsec1") || pasted.starts_with('{')) + && !import.password.is_empty(); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.cancel")) + .clicked() + { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action(ui, &t!("goblin.settings.import_btn"), false) + .clicked() + { + import.stage = 3; + let slot = import.result.clone(); + let nsec = std::mem::take(&mut import.nsec); + let password = std::mem::take(&mut import.password); + let bpw = std::mem::take(&mut import.backup_password); + let bpw = if bpw.is_empty() { None } else { Some(bpw) }; + let wallet = wallet.clone(); + std::thread::spawn(move || { + let res = + wallet.import_nostr_identity(nsec, password, bpw); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + 3 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.settings.importing")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 4 => { + ui.label( + RichText::new(t!("goblin.settings.identity_replaced")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.pos), + ); + ui.add_space(4.0); + let npub = &import.new_npub; + let short = if npub.len() > 18 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else { + npub.clone() + }; + ui.label( + RichText::new(t!("goblin.settings.now_using", npub => short)) + .font(FontId::new(13.0, fonts::mono())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new(t!("goblin.settings.import_failed")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&import.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() { + close = true; + } + } + } + }); + if close { + self.import_nsec = None; + } + } + + /// Inline username-claim widget (availability check + registration). + fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + // Poll the worker result; avatar invalidation happens after the + // claim borrow is released. + let mut invalidate_avatar: Option = None; + { + let claim = self.claim.as_mut().unwrap(); + if let Some(msg) = claim.result.lock().unwrap().take() { + claim.checking = false; + match msg { + ClaimMsg::Availability(avail) => { + let (available, msg) = availability_feedback(avail); + claim.available = available; + claim.message = Some(msg.to_string()); + } + ClaimMsg::Registered(nip05) => { + let name = nip05.split('@').next().unwrap_or("").to_string(); + claim.message = + Some(t!("goblin.settings.registered", name => name).to_string()); + claim.available = Some(true); + claim.input.clear(); + // Persist nip05 on the identity and republish. + if let Some(s) = wallet.nostr_service() { + { + let mut id = s.identity.write(); + id.nip05 = Some(nip05.clone()); + id.anonymous = false; + } + s.save_identity(); + } + // Publish kind 0 NOW so others can resolve our @name without + // waiting for the next app start — otherwise a just-claimed + // name is invisible over the relays (no kind-0 event exists). + wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile); + } + ClaimMsg::Released => { + claim.message = Some(t!("goblin.settings.released_msg").to_string()); + claim.available = None; + claim.confirm_release = false; + if let Some(s) = wallet.nostr_service() { + let name = { + let mut id = s.identity.write(); + let n = id.nip05.take(); + id.anonymous = true; + n + }; + s.save_identity(); + invalidate_avatar = + name.map(|n| n.split('@').next().unwrap_or("").to_string()); + } + } + ClaimMsg::Error(e) => { + claim.available = Some(false); + claim.message = Some(e); + } + } + } + } + if let Some(name) = invalidate_avatar { + self.avatars.invalidate(&name); + } + let claim = self.claim.as_mut().unwrap(); + + let registered: Option = wallet + .nostr_service() + .and_then(|s| s.identity.read().nip05.clone()) + .map(|n| n.split('@').next().unwrap_or("").to_string()); + + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + if let Some(name) = registered { + if claim.confirm_release { + // The are-you-sure gate. + ui.label( + RichText::new(t!("goblin.settings.release_confirm", name => name)) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.settings.release_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if claim.checking { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.settings.releasing")) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } else { + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card_ink( + ui, + &t!("goblin.settings.keep_it"), + t.surface_text, + ) + .clicked() + { + claim.confirm_release = false; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card_ink( + ui, + &t!("goblin.settings.release_it"), + t.neg, + ) + .clicked() + { + start_release(claim, &name, wallet); + } + }, + ); + }); + } + } else { + ui.label( + RichText::new(t!("goblin.settings.username")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(name.to_string()) + .font(FontId::new(20.0, fonts::bold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.settings.username_note")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if let Some(msg) = &claim.message { + ui.add_space(6.0); + ui.label( + RichText::new(msg) + .font(FontId::new(13.0, fonts::regular())) + .color(match claim.available { + Some(false) => t.neg, + Some(true) => t.pos, + None => t.surface_text_dim, + }), + ); + } + ui.add_space(10.0); + if w::big_action_on_card_ink(ui, &t!("goblin.settings.release_username"), t.neg) + .clicked() + { + claim.confirm_release = true; + claim.message = None; + } + } + } else { + ui.label( + RichText::new(t!("goblin.settings.pick_username")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(8.0); + // Placeholder shows the bare handle ("yourname") — we never display + // the "@" to users. A leading "@" the user happens to type is still + // stripped when the name is read below. + let before = claim.input.clone(); + TextEdit::new(egui::Id::from("settings_claim")) + .focus(false) + .hint_text("yourname") + .text_color(t.surface_text) + .body() + .ui(ui, &mut claim.input, cb); + if claim.input != before { + claim.available = None; + claim.message = None; + } + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.settings.username_note")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if let Some(msg) = &claim.message { + ui.add_space(6.0); + ui.label( + RichText::new(msg) + .font(FontId::new(13.0, fonts::regular())) + .color(match claim.available { + Some(false) => t.neg, + Some(true) => t.pos, + None => t.surface_text_dim, + }), + ); + } + ui.add_space(10.0); + let name = claim.input.trim().trim_start_matches('@').to_lowercase(); + let valid = name.len() >= 3 && name.len() <= 20; + if claim.checking { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.settings.working")).color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } else { + ui.add_enabled_ui(valid, |ui| { + if w::big_action(ui, &t!("goblin.settings.claim"), false).clicked() { + start_claim_flow(claim, &name, wallet); + } + }); + } + } + }); + } +} + +/// Spawn the combined claim: availability check first, then registration +/// in the same worker — one button, no separate Check step. +fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) { + let Some(service) = wallet.nostr_service() else { + return; + }; + let server = service.config.read().nip05_server(); + // Reuse the service's keys directly — never round-trip the secret through a + // plaintext nsec String to rebuild keys the service already holds. + let keys = service.keys(); + claim.checking = true; + claim.message = None; + claim.available = None; + let slot = claim.result.clone(); + let name = name.to_string(); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return, + }; + let msg = rt.block_on(async { + use crate::nostr::nip05::{Availability, RegisterResult, check_availability, register}; + match check_availability(&server, &name).await { + Availability::Available => match register(&server, &name, &keys).await { + RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05), + RegisterResult::Conflict(_) => { + ClaimMsg::Error(t!("goblin.settings.err_just_taken").to_string()) + } + RegisterResult::Rejected(e) if e == "name_change_cooldown" => { + ClaimMsg::Error(t!("goblin.settings.err_cooldown").to_string()) + } + RegisterResult::Rejected(e) => ClaimMsg::Error(e), + RegisterResult::Network => { + ClaimMsg::Error(t!("goblin.settings.err_unreachable").to_string()) + } + }, + other => ClaimMsg::Availability(other), + } + }); + *slot.lock().unwrap() = Some(msg); + }); +} + +/// Spawn the username release; the server deletes its avatar with it. +fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) { + let Some(service) = wallet.nostr_service() else { + return; + }; + let server = service.config.read().nip05_server(); + // Reuse the service's keys directly — never round-trip the secret through a + // plaintext nsec String to rebuild keys the service already holds. + let keys = service.keys(); + claim.checking = true; + claim.message = None; + let slot = claim.result.clone(); + let name = name.to_string(); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return, + }; + // Release is always allowed server-side (it's what arms the cooldown), + // so there's no cooldown rejection to handle here. + let msg = match rt.block_on(crate::nostr::nip05::unregister(&server, &name, &keys)) { + Ok(()) => ClaimMsg::Released, + Err(e) => ClaimMsg::Error(t!("goblin.settings.err_release", err => e).to_string()), + }; + *slot.lock().unwrap() = Some(msg); + }); +} + +/// Process a picked picture and upload it as the avatar for an owned name. + +/// Draw the small Goblin mascot mark. +pub fn widgets_logo(ui: &mut egui::Ui) { + widgets_logo_sized(ui, 24.0); +} + +/// Tinted goblin mark at a given size. +pub fn widgets_logo_sized(ui: &mut egui::Ui, size: f32) { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + // Chip-sized marks use a pre-rendered 48px raster: cleaner antialiasing + // at ~24px than runtime svg rasterization, with 2x headroom for hidpi. + let img = egui::Image::new(if size <= 32.0 { + egui::include_image!("../../../../img/goblin-logo2-48.png") + } else { + egui::include_image!("../../../../img/goblin-logo2.svg") + }) + .tint(theme::tokens().text) + .fit_to_exact_size(Vec2::splat(size)); + img.paint_at(ui, rect); +} + +fn empty_state(ui: &mut egui::Ui, title: &str, subtitle: &str) { + let t = theme::tokens(); + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(title) + .font(FontId::new(17.0, fonts::semibold())) + .color(t.text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(subtitle) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); +} + +fn settings_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { + w::kicker(ui, title); + ui.add_space(8.0); + w::card(ui, |ui| add(ui)); +} + +/// Title row for an Advanced-page action card. +fn advanced_head(ui: &mut egui::Ui, label: &str, color: Color32) { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::semibold())) + .color(color), + ); + ui.add_space(4.0); +} + +/// Wrapped description line under an Advanced-page action title. +fn advanced_desc(ui: &mut egui::Ui, text: &str) { + let t = theme::tokens(); + ui.label( + RichText::new(text) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); +} + +/// A settings row: label + subtitle on the left, an on/off switch on the right. +/// Returns `Some(new_value)` on the frame it is toggled. +fn settings_row_toggle(ui: &mut egui::Ui, label: &str, sub: &str, on: bool) -> Option { + let t = theme::tokens(); + let mut toggled = None; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.label( + RichText::new(sub) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if w::toggle(ui, on).clicked() { + toggled = Some(!on); + } + }); + }); + ui.add_space(10.0); + toggled +} + +fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { + settings_row_ink(ui, label, value, theme::tokens().surface_text_dim); +} + +/// Like [`settings_row`] but the value is drawn in an explicit ink — used to flag +/// the always-on mixnet routing in the privacy color. +fn settings_row_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) { + let t = theme::tokens(); + ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(value) + .font(FontId::new(13.0, fonts::regular())) + .color(value_ink), + ); + }); + }); + ui.add_space(10.0); +} + +fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { + let t = theme::tokens(); + let mut clicked = false; + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let resp = ui.label( + RichText::new(icon) + .font(FontId::new(18.0, fonts::regular())) + .color(t.surface_text_dim), + ); + if resp.interact(Sense::click()).clicked() { + clicked = true; + } + }); + }); + ui.add_space(10.0); + // The whole row is tappable, not just the trailing value/icon. + clicked || row.response.interact(Sense::click()).clicked() +} + +/// A danger-styled settings row button (whole row taps). +fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { + let t = theme::tokens(); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.neg), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(icon) + .font(FontId::new(18.0, fonts::regular())) + .color(t.neg), + ); + }); + }); + ui.add_space(10.0); + row.response.interact(Sense::click()).clicked() +} + +/// A settings row that navigates somewhere: value + chevron, whole row taps. +fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool { + let t = theme::tokens(); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(crate::gui::icons::CARET_RIGHT) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_mute), + ); + ui.add_space(4.0); + ui.label( + RichText::new(value) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + ui.add_space(10.0); + row.response.interact(Sense::click()).clicked() +} + +/// Like [`settings_row_nav`] but the value is drawn in an explicit ink — used to +/// flag the mixnet-routing row in the privacy color while still navigating. +fn settings_row_nav_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) -> bool { + let t = theme::tokens(); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(crate::gui::icons::CARET_RIGHT) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_mute), + ); + ui.add_space(4.0); + ui.label( + RichText::new(value) + .font(FontId::new(13.0, fonts::regular())) + .color(value_ink), + ); + }); + }); + ui.add_space(10.0); + row.response.interact(Sense::click()).clicked() +} + +/// One channel row on the Network-privacy page: a status dot, a title and a +/// wrapped blurb explaining where that traffic goes. +fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) { + let t = theme::tokens(); + ui.horizontal_top(|ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::new(14.0, 20.0), Sense::hover()); + ui.painter() + .circle_filled(rect.center() + Vec2::new(0.0, -2.0), 4.0, dot); + ui.vertical(|ui| { + ui.label( + RichText::new(title) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(2.0); + ui.label( + RichText::new(blurb) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + ui.add_space(10.0); +} + +/// Open a URL in the system browser. +fn open_url(ui: &egui::Ui, url: &str) { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); +} + +/// Linear blend between two colors (`p` 0→`a`, 1→`b`). Used by the Pay-screen +/// over-balance flash to ease the digits from red back to normal ink. +fn lerp_color(a: Color32, b: Color32, p: f32) -> Color32 { + let p = p.clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * p).round() as u8; + Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b())) +} + +fn approve_button(ui: &mut egui::Ui) -> bool { + w::big_action(ui, &t!("goblin.request.approve"), false).clicked() +} + +fn decline_button(ui: &mut egui::Ui) -> bool { + w::big_action(ui, &t!("goblin.request.decline"), true).clicked() +} + +fn accept_policy_label(wallet: &Wallet) -> String { + use crate::nostr::config::AcceptPolicy; + wallet + .nostr_service() + .map(|s| match s.config.read().accept_from() { + AcceptPolicy::Everyone => t!("goblin.settings.accept_anyone").to_string(), + AcceptPolicy::Contacts => t!("goblin.settings.accept_contacts").to_string(), + AcceptPolicy::Ask => t!("goblin.settings.accept_ask").to_string(), + }) + .unwrap_or_else(|| t!("goblin.settings.accept_anyone").to_string()) +} + +/// Cycle the color theme Dark ↔ Light and re-apply visuals. Yellow is kept +/// defined (gui/theme.rs) but out of the picker for now — it's still in beta; +/// `Yellow => Dark` is an escape hatch for anyone whose config already has it. +fn cycle_theme(ctx: &egui::Context) { + use crate::gui::theme::ThemeKind; + let next = match crate::AppConfig::theme() { + ThemeKind::Dark => ThemeKind::Light, + ThemeKind::Light => ThemeKind::Dark, + ThemeKind::Yellow => ThemeKind::Dark, + }; + crate::AppConfig::set_theme(next); + crate::setup_visuals(ctx); +} + +/// Cycle the density Comfy → Regular → Compact → Comfy. +/// Cycle the incoming-payment accept policy Anyone → Contacts → Ask → Anyone. +fn cycle_accept_policy(wallet: &Wallet) { + use crate::nostr::config::AcceptPolicy; + if let Some(s) = wallet.nostr_service() { + let next = match s.config.read().accept_from() { + AcceptPolicy::Everyone => AcceptPolicy::Contacts, + AcceptPolicy::Contacts => AcceptPolicy::Ask, + AcceptPolicy::Ask => AcceptPolicy::Everyone, + }; + s.config.write().set_accept_from(next); + } +} + +fn relay_summary(wallet: &Wallet) -> String { + wallet + .nostr_service() + .map(|s| { + let relays = s.relays(); + match relays.len() { + 0 => t!("goblin.relays.none").to_string(), + 1 => relays[0].replace("wss://", ""), + n => t!("goblin.relays.count", n => n).to_string(), + } + }) + .unwrap_or_else(|| "—".to_string()) +} + +/// Compute a fiat preview line for the balance, when a rate is available. +/// One-line node summary: "Block 1,847,221 · main.gri.mw". +/// Bare node host (or "integrated node") for the sidebar card's third line. +fn node_host(wallet: &Wallet) -> String { + match wallet.get_current_connection() { + crate::wallet::types::ConnectionMethod::Integrated => { + t!("goblin.node.integrated_host").to_string() + } + crate::wallet::types::ConnectionMethod::External(_, url) => url + .replace("https://", "") + .replace("http://", "") + .trim_end_matches('/') + .to_string(), + } +} + +fn node_summary(wallet: &Wallet) -> String { + let height = wallet + .get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0); + let conn = match wallet.get_current_connection() { + crate::wallet::types::ConnectionMethod::Integrated => { + t!("goblin.node.integrated_host").to_string() + } + crate::wallet::types::ConnectionMethod::External(_, url) => url + .replace("https://", "") + .replace("http://", "") + .trim_end_matches('/') + .to_string(), + }; + if height == 0 { + t!("goblin.node.summary_syncing", conn => conn).to_string() + } else { + t!("goblin.node.summary_block", height => fmt_thousands(height), conn => conn).to_string() + } +} + +/// Format a number with thousands separators. +fn fmt_thousands(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + out +} + +fn fiat_line(data: &Option) -> Option { + let p = crate::AppConfig::pairing(); + let vs = p.vs_currency()?; + let rate = crate::http::grin_rate(vs)?; + let spendable = data + .as_ref() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + let grin = spendable as f64 / 1_000_000_000.0; + Some(format!( + "≈ {} · 1ツ = {}", + fmt_pairing(grin * rate, p), + fmt_pairing(rate, p) + )) +} + +/// Format a value already in the pairing's unit (dollars, BTC, …) with the +/// right symbol/precision. Sats scales the BTC value by 1e8. +fn fmt_pairing(value: f64, p: crate::settings::Pairing) -> String { + use crate::settings::Pairing; + match p { + Pairing::Usd => format!("${:.2}", value), + Pairing::Eur => format!("€{:.2}", value), + Pairing::Gbp => format!("£{:.2}", value), + Pairing::Jpy => format!("¥{:.0}", value), + Pairing::Cny => format!("CN¥{:.2}", value), + Pairing::Btc => { + let s = format!("{:.8}", value); + let s = s.trim_end_matches('0').trim_end_matches('.'); + format!("₿{}", if s.is_empty() { "0" } else { s }) + } + Pairing::Sats => format!("{} sats", fmt_thousands((value * 1e8).round() as u64)), + Pairing::Off => String::new(), + } +} + +/// The "≈ …" amount preview for the current pairing, or `None` when off / no +/// rate yet. Shared by the Pay screen, the send flow, and the balance hero. +fn pairing_preview(grin: f64) -> Option { + let p = crate::AppConfig::pairing(); + let vs = p.vs_currency()?; + let rate = crate::http::grin_rate(vs)?; + Some(format!("≈ {}", fmt_pairing(grin * rate, p))) +} + +/// Convert a bech32 npub to hex for short display fallbacks. +fn hex_of(npub: &str) -> String { + use nostr_sdk::{FromBech32, PublicKey}; + PublicKey::from_bech32(npub) + .map(|pk| pk.to_hex()) + .unwrap_or_else(|_| npub.to_string()) +} diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs new file mode 100644 index 00000000..52166141 --- /dev/null +++ b/src/gui/views/goblin/onboarding.rs @@ -0,0 +1,1203 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! First-run onboarding: what Goblin is → node choice → wallet create or +//! restore → optional payment-identity username. Wraps GRIM's mnemonic and +//! wallet-creation machinery without replacing it — the stock creation flow +//! stays available from the wallet list for later wallets. + +use eframe::epaint::FontId; +use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2}; +use grin_util::ZeroingString; + +use crate::gui::icons::ARROW_LEFT; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::theme::{self, fonts}; +use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult}; +use crate::gui::views::wallets::creation::MnemonicSetup; +use crate::gui::views::{CameraScanContent, Content, Modal, TextEdit, View}; +use crate::node::Node; +use crate::nostr::NostrIdentity; +use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize}; +use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList}; + +use super::widgets::{self as w}; +use super::{ClaimMsg, ClaimState, start_claim_flow}; + +/// Identifier for the recovery-phrase QR scan [`Modal`]. +const OB_PHRASE_SCAN_MODAL: &'static str = "ob_phrase_scan_modal"; + +/// Onboarding step. +#[derive(PartialEq, Eq, Clone, Copy)] +#[allow(dead_code)] // Node step retired from the flow; node mgmt lives in Settings/Advanced +enum Step { + Intro, + Node, + WalletSetup, + Words, + ConfirmWords, + Identity, +} + +/// First-run onboarding content. +pub struct OnboardingContent { + step: Step, + /// Node choice: integrated (own node) or external URL. + integrated: bool, + ext_url: String, + /// Wallet setup inputs. + restore: bool, + name: String, + pass: String, + pass2: String, + /// GRIM's mnemonic machinery (word grid, validation, import). + mnemonic_setup: MnemonicSetup, + /// Wallet creation error, if any. + error: Option, + /// QR scanner for recovery phrase import. + scan_modal: Option, + /// Created and opened wallet, present from the Identity step on. + wallet: Option, + /// Optional username claim state (same machinery as Settings). + claim: ClaimState, + /// Optional "import an existing identity" sub-flow, opened from the identity + /// step so a returning user can keep their old npub + username instead of the + /// freshly-generated random key. + import: Option, +} + +/// Onboarding identity-import state. Reuses the wallet password the user just +/// set, so it only needs the backup file / nsec (and the backup's own password +/// when restoring a sealed `.backup`). +#[derive(Default)] +struct OnbImport { + /// 0 = form, 1 = working, 2 = error. + stage: u8, + /// Pasted nsec or the read-in contents of a `.backup` / identity JSON file. + nsec: String, + /// Password the backup was sealed under (blank for a bare nsec, or when it + /// matches this wallet's password). + backup_password: String, + /// Last import error, shown on stage 2. + error: String, + /// A native file pick is in flight (Android resolves the path asynchronously). + picking: bool, + /// Worker result: Ok(new npub) or Err(message). + result: std::sync::Arc>>>, +} + +impl Default for OnboardingContent { + fn default() -> Self { + Self { + step: Step::Intro, + // Default to the Instant path (connect to a public node) so a new + // user is online immediately, with no chain-sync wait. + integrated: false, + ext_url: "https://api.grin.money".to_string(), + restore: false, + name: "Main wallet".to_string(), + pass: String::new(), + pass2: String::new(), + mnemonic_setup: MnemonicSetup::default(), + error: None, + scan_modal: None, + wallet: None, + claim: ClaimState::default(), + import: None, + } + } +} + +impl OnboardingContent { + /// Render onboarding. Returns the wallet once the user finishes the + /// final step, so the host can select it and drop this content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallets: &mut WalletList, + cb: &dyn PlatformCallbacks, + ) -> Option { + // Draw owned modals (word input, phrase scan) when opened. + if let Some(id) = Modal::opened() { + if id == OB_PHRASE_SCAN_MODAL { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.scan_modal_ui(ui, modal, cb); + }); + } else if self.mnemonic_setup.modal_ids().contains(&id) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.mnemonic_setup.modal_ui(ui, modal, cb); + }); + } + } + + let mut done = None; + ScrollArea::vertical() + .id_salt("goblin_onboarding") + .auto_shrink([false; 2]) + .show(ui, |ui| { + w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| { + ui.add_space(View::get_top_inset() + 24.0); + match self.step { + Step::Intro => self.intro_ui(ui), + Step::Node => self.node_ui(ui, cb), + Step::WalletSetup => self.wallet_setup_ui(ui, cb), + Step::Words => self.words_ui(ui, wallets, cb), + Step::ConfirmWords => self.confirm_ui(ui, wallets, cb), + Step::Identity => done = self.identity_ui(ui, cb), + } + ui.add_space(View::get_bottom_inset() + 24.0); + }); + }); + done + } + + /// Back chip + step kicker shared by all steps after the intro. + fn step_header(&mut self, ui: &mut egui::Ui, kicker: &str, title: &str, back: Step) { + let t = theme::tokens(); + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + ARROW_LEFT, + FontId::new(16.0, fonts::regular()), + t.surface_text, + ); + if resp + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + self.error = None; + self.step = back; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(kicker) + .font(fonts::kicker()) + .color(t.text_mute), + ); + }); + }); + ui.add_space(18.0); + ui.label( + RichText::new(title) + .font(FontId::new(26.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(14.0); + } + + // ── Intro ──────────────────────────────────────────────────────────── + + fn intro_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + ui.add_space(26.0); + ui.vertical_centered(|ui| { + super::widgets_logo_sized(ui, 72.0); + ui.add_space(14.0); + ui.label( + RichText::new("goblin") + .font(FontId::new(34.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(26.0); + let lines: [(String, String); 3] = [ + ( + t!("goblin.onboarding.intro.private_money_head").to_string(), + t!("goblin.onboarding.intro.private_money_body").to_string(), + ), + ( + t!("goblin.onboarding.intro.send_like_message_head").to_string(), + t!("goblin.onboarding.intro.send_like_message_body").to_string(), + ), + ( + t!("goblin.onboarding.intro.yours_alone_head").to_string(), + t!("goblin.onboarding.intro.yours_alone_body").to_string(), + ), + ]; + for (head, body) in lines { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(head) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(body) + .font(FontId::new(13.5, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.add_space(10.0); + } + ui.add_space(16.0); + if w::big_action(ui, &t!("goblin.onboarding.intro.get_started"), false).clicked() { + self.step = Step::WalletSetup; + } + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.onboarding.intro.footnote")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_mute), + ); + }); + } + + // ── Node choice ────────────────────────────────────────────────────── + + fn node_card(ui: &mut egui::Ui, selected: bool, title: &str, word: &str, body: &str) -> bool { + let t = theme::tokens(); + let resp = ui + .scope(|ui| { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::hover()); + ui.painter().circle_stroke( + dot.center(), + 8.0, + eframe::epaint::Stroke::new(1.5, t.surface_text_mute), + ); + if selected { + ui.painter().circle_filled(dot.center(), 5.0, t.accent); + } + ui.add_space(8.0); + ui.label( + RichText::new(title) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let galley = ui.painter().layout_no_wrap( + word.to_string(), + FontId::new(12.0, fonts::semibold()), + t.bg, + ); + let pad = Vec2::new(10.0, 5.0); + let (rect, _) = + ui.allocate_exact_size(galley.size() + pad * 2.0, Sense::hover()); + ui.painter().rect_filled( + rect, + eframe::epaint::CornerRadius::same(10), + t.accent, + ); + ui.painter().galley(rect.min + pad, galley, t.bg); + }); + }); + ui.add_space(6.0); + ui.label( + RichText::new(body) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }) + .response; + resp.interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + } + + fn node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + self.step_header( + ui, + &t!("goblin.onboarding.node.kicker"), + &t!("goblin.onboarding.node.title"), + Step::Intro, + ); + // Instant (connect to a public node) leads — most people want to be + // online immediately, with no chain-sync wait. + if Self::node_card( + ui, + !self.integrated, + &t!("goblin.onboarding.node.connect_title"), + &t!("goblin.onboarding.node.connect_badge"), + &t!("goblin.onboarding.node.connect_body"), + ) { + self.integrated = false; + } + if !self.integrated { + ui.add_space(10.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_ext_url")) + .focus(false) + .hint_text("https://node.example.com") + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.ext_url, cb); + }); + } + ui.add_space(10.0); + if Self::node_card( + ui, + self.integrated, + &t!("goblin.onboarding.node.own_title"), + &t!("goblin.onboarding.node.own_badge"), + &t!("goblin.onboarding.node.own_body"), + ) { + self.integrated = true; + } + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.onboarding.node.changeable")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_mute), + ); + ui.add_space(16.0); + let url_ok = self.integrated + || self.ext_url.trim().starts_with("http://") + || self.ext_url.trim().starts_with("https://"); + if w::big_action(ui, &t!("goblin.onboarding.node.continue"), false).clicked() && url_ok { + self.step = Step::WalletSetup; + } + if !url_ok { + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.onboarding.node.url_invalid")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } + } + + // ── Wallet name + password, create vs restore ─────────────────────── + + fn wallet_setup_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + self.step_header( + ui, + &t!("goblin.onboarding.wallet.kicker"), + &t!("goblin.onboarding.wallet.title"), + Step::Intro, + ); + + // Create / Restore segmented choice. + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + for (restore, label) in [ + (false, t!("goblin.onboarding.wallet.create_new")), + (true, t!("goblin.onboarding.wallet.restore_from_seed")), + ] { + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + let active = self.restore == restore; + let resp = w::chip(ui, &label, active); + if resp.clicked() { + self.restore = restore; + } + }, + ); + ui.add_space(10.0); + } + }); + ui.add_space(14.0); + + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_name")) + .focus(false) + .hint_text(t!("goblin.onboarding.wallet.name_hint")) + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.name, cb); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_pass")) + .focus(false) + .hint_text(t!("goblin.onboarding.wallet.password_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.pass, cb); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_pass2")) + .focus(false) + .hint_text(t!("goblin.onboarding.wallet.repeat_password_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.pass2, cb); + }); + ui.add_space(10.0); + ui.label( + RichText::new(if self.restore { + t!("goblin.onboarding.wallet.restore_hint") + } else { + t!("goblin.onboarding.wallet.create_hint") + }) + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_mute), + ); + ui.add_space(16.0); + + let pass_ok = !self.pass.is_empty() && self.pass == self.pass2; + let name_ok = !self.name.trim().is_empty(); + if w::big_action(ui, &t!("goblin.onboarding.wallet.continue"), false).clicked() + && pass_ok + && name_ok + { + self.mnemonic_setup.reset(); + self.mnemonic_setup.mnemonic.set_mode(if self.restore { + PhraseMode::Import + } else { + PhraseMode::Generate + }); + self.mnemonic_setup.mnemonic.set_size(PhraseSize::Words24); + self.error = None; + self.step = Step::Words; + } + if !self.pass.is_empty() && self.pass != self.pass2 { + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.onboarding.wallet.passwords_no_match")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } + } + + // ── Seed words (display for create, entry for restore) ────────────── + + fn words_ui( + &mut self, + ui: &mut egui::Ui, + wallets: &mut WalletList, + cb: &dyn PlatformCallbacks, + ) { + let t = theme::tokens(); + let restore = self.mnemonic_setup.mnemonic.mode() == PhraseMode::Import; + let words_title = if restore { + t!("goblin.onboarding.words.title_restore") + } else { + t!("goblin.onboarding.words.title_create") + }; + self.step_header( + ui, + &t!("goblin.onboarding.words.kicker"), + &words_title, + Step::WalletSetup, + ); + if restore { + // Word count picker for restores. + ui.horizontal(|ui| { + for size in PhraseSize::VALUES { + let label = format!("{}", size.value()); + let active = self.mnemonic_setup.mnemonic.size() == size; + if w::chip(ui, &label, active).clicked() { + self.mnemonic_setup.mnemonic.set_size(size); + } + ui.add_space(6.0); + } + }); + ui.add_space(10.0); + } else { + ui.label( + RichText::new(t!("goblin.onboarding.words.write_down_hint")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + } + + // GRIM's word grid (edit mode when restoring). + self.mnemonic_setup.word_list_ui(ui, restore); + ui.add_space(14.0); + + if restore { + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::chip(ui, &t!("goblin.onboarding.words.paste"), false).clicked() { + let data = ZeroingString::from(cb.get_string_from_buffer()); + self.mnemonic_setup.mnemonic.import(&data); + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::chip(ui, &t!("goblin.onboarding.words.scan_qr"), false).clicked() { + self.scan_modal = Some(CameraScanContent::default()); + Modal::new(OB_PHRASE_SCAN_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("scan_qr")) + .closeable(false) + .show(); + cb.start_camera(); + } + }, + ); + }); + ui.add_space(14.0); + } else if w::chip(ui, &t!("goblin.onboarding.words.copy_clipboard"), false).clicked() { + cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase()); + cb.vibrate_copy(); + } + if !restore { + ui.add_space(14.0); + } + + let ready = if restore { + !self.mnemonic_setup.mnemonic.has_empty_or_invalid() + } else { + true + }; + let label = if restore { + t!("goblin.onboarding.words.restore_wallet") + } else { + t!("goblin.onboarding.words.wrote_them_down") + }; + if ready { + if w::big_action(ui, &label, false).clicked() { + if restore { + self.create_wallet(wallets); + } else { + self.step = Step::ConfirmWords; + } + } + } else { + ui.label( + RichText::new(t!("goblin.onboarding.words.fill_every_word")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_mute), + ); + } + self.error_ui(ui); + } + + fn confirm_ui( + &mut self, + ui: &mut egui::Ui, + wallets: &mut WalletList, + cb: &dyn PlatformCallbacks, + ) { + let t = theme::tokens(); + self.step_header( + ui, + &t!("goblin.onboarding.confirm.kicker"), + &t!("goblin.onboarding.confirm.title"), + Step::Words, + ); + ui.label( + RichText::new(t!("goblin.onboarding.confirm.enter_hint")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + self.mnemonic_setup.word_list_ui(ui, true); + ui.add_space(14.0); + if w::chip(ui, &t!("goblin.onboarding.confirm.paste"), false).clicked() { + let data = ZeroingString::from(cb.get_string_from_buffer()); + self.mnemonic_setup.mnemonic.import(&data); + } + ui.add_space(14.0); + if !self.mnemonic_setup.mnemonic.has_empty_or_invalid() { + if w::big_action(ui, &t!("goblin.onboarding.confirm.create_wallet"), false).clicked() { + self.create_wallet(wallets); + } + } else { + ui.label( + RichText::new(t!("goblin.onboarding.confirm.keep_going")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_mute), + ); + } + self.error_ui(ui); + } + + fn error_ui(&self, ui: &mut egui::Ui) { + if let Some(err) = &self.error { + ui.add_space(10.0); + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(theme::tokens().neg), + ); + } + } + + /// Resolve the connection method, create the wallet, open it and move + /// to the identity step. + fn create_wallet(&mut self, wallets: &mut WalletList) { + // Connection: integrated starts the local node; external reuses an + // existing saved connection with the same URL or saves a new one. + let method = if self.integrated { + if !Node::is_running() { + Node::start(); + } + ConnectionMethod::Integrated + } else { + let url = self.ext_url.trim().trim_end_matches('/').to_string(); + let existing = ConnectionsConfig::ext_conn_list() + .into_iter() + .find(|c| c.url.trim_end_matches('/') == url); + let conn = match existing { + Some(c) => c, + None => { + let c = ExternalConnection::new(url, None, None); + ConnectionsConfig::add_ext_conn(c.clone()); + c + } + }; + ConnectionMethod::External(conn.id, conn.url.clone()) + }; + + let pass = ZeroingString::from(self.pass.clone()); + match Wallet::create( + &self.name.trim().to_string(), + &pass, + &self.mnemonic_setup.mnemonic, + &method, + ) { + Ok(w) => { + self.mnemonic_setup.reset(); + wallets.add(w.clone()); + match w.open(pass) { + Ok(_) => { + self.wallet = Some(w); + self.error = None; + self.step = Step::Identity; + } + Err(e) => { + self.error = Some( + t!("goblin.onboarding.errors.cant_open", err => format!("{:?}", e)) + .to_string(), + ) + } + } + } + Err(e) => { + self.error = Some( + t!("goblin.onboarding.errors.cant_create", err => format!("{:?}", e)) + .to_string(), + ) + } + } + } + + // ── Identity (optional username) ───────────────────────────────────── + + fn identity_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) -> Option { + let t = theme::tokens(); + // No back from here: the wallet exists now. + ui.label( + RichText::new(t!("goblin.onboarding.identity.kicker")) + .font(fonts::kicker()) + .color(t.text_mute), + ); + ui.add_space(18.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.title")) + .font(FontId::new(26.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(14.0); + + let wallet = self.wallet.clone()?; + let service = wallet.nostr_service(); + let (npub, connected) = service + .as_ref() + .map(|s| (s.npub(), s.is_connected())) + .unwrap_or((String::new(), false)); + // The claimed @name (bare), if any — so the identity card shows the name + // instead of the npub once a username is registered. + let claimed_name = service + .as_ref() + .and_then(|s| s.identity.read().nip05.clone()) + .and_then(|n| n.split('@').next().map(|s| s.to_string())) + .filter(|n| !n.is_empty()); + + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + // Same deterministic gradient + Grin mark the rest of the app shows + // for this key; only fall back to a placeholder while the key is + // still being generated (npub not yet available). + if npub.is_empty() { + w::avatar(ui, "N", 44.0, 6); + } else { + w::gradient_avatar(ui, &npub, 44.0); + } + ui.add_space(10.0); + ui.vertical(|ui| { + // Once claimed, show the @name (with a check) instead of the npub + // so the user can SEE the username applied. + if let Some(name) = &claimed_name { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + ui.label( + RichText::new(name) + .font(FontId::new(16.0, fonts::bold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(crate::gui::icons::SEAL_CHECK) + .font(FontId::new(14.0, fonts::regular())) + .color(t.pos), + ); + }); + } else { + let short = if npub.len() > 20 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else if npub.is_empty() { + t!("goblin.onboarding.identity.key_being_made").to_string() + } else { + npub.clone() + }; + ui.label( + RichText::new(short) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + } + ui.label( + RichText::new(if connected { + t!("goblin.onboarding.identity.connected_nym") + } else { + t!("goblin.onboarding.identity.connecting_nym") + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + }); + }); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.fresh_key_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.add_space(14.0); + + // Optional username claim — the same machinery as Settings. + if let Some(msg) = self.claim.result.lock().unwrap().take() { + self.claim.checking = false; + match msg { + ClaimMsg::Availability(avail) => { + let (available, msg) = super::availability_feedback(avail); + self.claim.available = available; + self.claim.message = Some(msg.to_string()); + } + ClaimMsg::Registered(nip05) => { + self.claim.message = Some( + t!( + "goblin.onboarding.identity.youre", + name => nip05.split('@').next().unwrap_or("") + ) + .to_string(), + ); + self.claim.available = Some(true); + if let Some(s) = wallet.nostr_service() { + { + let mut id = s.identity.write(); + id.nip05 = Some(nip05.clone()); + id.anonymous = false; + } + s.save_identity(); + } + // Publish kind 0 now so the just-claimed name is visible to + // others over the relay without waiting for the next app start. + wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile); + } + ClaimMsg::Released => {} + ClaimMsg::Error(e) => { + self.claim.available = Some(false); + self.claim.message = Some(e); + } + } + } + let registered = wallet + .nostr_service() + .map(|s| s.identity.read().nip05.is_some()) + .unwrap_or(false); + if self.import.is_some() { + // Returning user is swapping the random key for their existing identity. + self.import_ui(ui, &wallet, cb); + } else if !registered { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(t!("goblin.onboarding.identity.pick_username")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.username_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(8.0); + w::field_well(ui, |ui| { + ui.horizontal(|ui| { + let before = self.claim.input.clone(); + TextEdit::new(egui::Id::from("onb_claim")) + .focus(false) + .hint_text(t!("goblin.onboarding.identity.username_field_hint")) + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.claim.input, cb); + if self.claim.input != before { + self.claim.available = None; + self.claim.message = None; + } + }); + }); + if let Some(msg) = &self.claim.message { + ui.add_space(6.0); + ui.label( + RichText::new(msg) + .font(FontId::new(13.0, fonts::regular())) + .color(match self.claim.available { + Some(false) => t.neg, + Some(true) => t.pos, + None => t.surface_text_dim, + }), + ); + } + ui.add_space(10.0); + let name = self.claim.input.trim().to_lowercase(); + let valid = name.len() >= 3 && name.len() <= 20; + if self.claim.checking { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.working")) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } else { + ui.add_enabled_ui(valid && connected, |ui| { + if w::big_action_on_card( + ui, + &t!("goblin.onboarding.identity.claim_username"), + ) + .clicked() + { + start_claim_flow(&mut self.claim, &name, &wallet); + } + }); + if !connected { + ui.add_space(6.0); + ui.label( + RichText::new(t!( + "goblin.onboarding.identity.available_when_connected" + )) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + } + } + }); + ui.add_space(10.0); + // Returning user? Let them restore their existing identity (nsec or a + // .backup file) instead of claiming a fresh name on the random key. + let import_resp = ui + .add( + egui::Label::new( + RichText::new(t!("goblin.onboarding.identity.import_existing")) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.accent), + ) + .sense(Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + if import_resp.clicked() { + self.import = Some(OnbImport::default()); + } + ui.add_space(16.0); + } else { + // Claimed: show a clear success confirmation so the user knows the + // username stuck before they tap through to the wallet. + let claimed = claimed_name.clone().unwrap_or_default(); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + ui.label( + RichText::new(crate::gui::icons::SEAL_CHECK) + .font(FontId::new(22.0, fonts::regular())) + .color(t.pos), + ); + ui.vertical(|ui| { + ui.label( + RichText::new(t!( + "goblin.onboarding.identity.claimed_title", + name => &claimed + )) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(2.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.claimed_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + }); + ui.add_space(16.0); + } + + if !connected { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(500)); + } + + let main_label = if registered { + t!("goblin.onboarding.identity.open_wallet") + } else { + t!("goblin.onboarding.identity.skip_for_now") + }; + if w::big_action(ui, &main_label, false).clicked() { + return Some(wallet); + } + None + } + + /// Onboarding identity-import sub-flow: paste an nsec or pick a `.backup` + /// file to swap the freshly-generated random key for the user's existing + /// identity (keeping their npub and any claimed username). Reuses the wallet + /// password the user just set; a sealed `.backup` may carry its own password. + fn import_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + // Poll the worker first, WITHOUT holding a borrow across the reset below. + if self.import.as_ref().map(|i| i.stage) == Some(1) { + let res = self.import.as_ref().unwrap().result.lock().unwrap().take(); + if let Some(res) = res { + match res { + // Identity replaced: drop the sub-flow; the identity card and the + // claim/success state re-render from the new service next frame. + Ok(_) => { + self.import = None; + return; + } + Err(e) => { + let imp = self.import.as_mut().unwrap(); + imp.error = e; + imp.stage = 2; + } + } + } + } + let wallet_pass = self.pass.clone(); + let imp = self.import.as_mut().unwrap(); + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match imp.stage { + 1 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.settings.importing")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 2 => { + ui.label( + RichText::new(t!("goblin.settings.import_failed")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&imp.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new(t!("goblin.onboarding.identity.import_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.import_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + // Native ".backup file" picker. Desktop returns the path now; + // Android resolves it asynchronously (poll picked_file()). + if imp.picking { + if let Some(path) = cb.picked_file() { + imp.picking = false; + if !path.is_empty() { + match std::fs::read_to_string(&path) { + Ok(contents) => imp.nsec = contents.trim().to_string(), + Err(_) => { + imp.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + } else { + ui.ctx().request_repaint(); + } + } + if w::big_action_on_card(ui, &t!("goblin.settings.choose_backup_file")) + .clicked() + { + imp.error.clear(); + match cb.pick_file() { + Some(path) if !path.is_empty() => { + match std::fs::read_to_string(&path) { + Ok(contents) => imp.nsec = contents.trim().to_string(), + Err(_) => { + imp.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + // Empty string = Android async pick in flight. + Some(_) => imp.picking = true, + None => {} + } + } + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_import_nsec")) + .focus(false) + .hint_text(t!("goblin.settings.import_nsec_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut imp.nsec, cb); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_import_bpw")) + .focus(false) + .hint_text(t!("goblin.settings.backup_password_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut imp.backup_password, cb); + }); + if !imp.error.is_empty() { + ui.add_space(6.0); + ui.label( + RichText::new(&imp.error) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(10.0); + let pasted = imp.nsec.trim(); + // Only an nsec paste or a sealed .backup file — nothing else. + let armed = + pasted.starts_with("nsec1") || NostrIdentity::is_encrypted_backup(pasted); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.cancel")) + .clicked() + { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action(ui, &t!("goblin.settings.import_btn"), false) + .clicked() + { + imp.stage = 1; + let slot = imp.result.clone(); + let nsec = std::mem::take(&mut imp.nsec); + let bpw = std::mem::take(&mut imp.backup_password); + let bpw = if bpw.is_empty() { None } else { Some(bpw) }; + let wallet = wallet.clone(); + let pass = wallet_pass.clone(); + std::thread::spawn(move || { + let res = wallet.import_nostr_identity(nsec, pass, bpw); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + } + }); + if close { + self.import = None; + } + } + + /// Recovery-phrase QR scan modal content. + fn scan_modal_ui(&mut self, ui: &mut egui::Ui, _: &Modal, cb: &dyn PlatformCallbacks) { + if let Some(content) = self.scan_modal.as_mut() { + content.modal_ui(ui, cb, |result| match result { + QrScanResult::Text(text) => { + self.mnemonic_setup.mnemonic.import(&text); + Modal::close(); + } + QrScanResult::SeedQR(text) => { + self.mnemonic_setup.mnemonic.import(&text); + Modal::close(); + } + _ => {} + }); + } + } +} diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs new file mode 100644 index 00000000..e9fc7cb7 --- /dev/null +++ b/src/gui/views/goblin/send.rs @@ -0,0 +1,1500 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! The Goblin send flow: pick recipient → enter amount → review → success. + +use eframe::epaint::FontId; +use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2}; +use grin_core::core::amount_from_hr_string; + +use crate::gui::Colors; +use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, SHARE, USERS}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::theme::{self, fonts}; +use crate::gui::views::types::{ModalPosition, QrScanResult}; +use crate::gui::views::{CameraContent, Modal, TextEdit, View}; + +/// Modal id for the send-flow note editor (floats above the soft keyboard). +const NOTE_MODAL: &str = "send_note_modal"; +use crate::nostr::nip05; +use crate::wallet::Wallet; +use crate::wallet::types::WalletTask; + +use super::avatars::AvatarTextures; +use super::data::{self, display_name, recent_peers, search_contacts, short_npub}; +use super::widgets::{self as w, HoldToSend}; + +/// Avatar texture for a display handle, if one is cached. Handles no longer +/// carry an '@'; bare-npub / empty names have no avatar on the server. +fn tex_for( + avatars: &mut AvatarTextures, + ctx: &egui::Context, + wallet: &Wallet, + name: &str, +) -> Option { + if name.is_empty() || name.starts_with("npub1") { + return None; + } + let server = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server())?; + avatars.texture_for(ctx, &server, name) +} + +/// Stage of the send flow. +#[derive(PartialEq, Eq)] +enum Stage { + Recipient, + Amount, + Review, + Sending, + Success, + Failed, +} + +/// The two halves of the scan-to-pay screen: the camera, or your own code. +#[derive(Clone, Copy, PartialEq, Default)] +enum ScanTab { + #[default] + Scan, + MyCode, +} + +/// A resolved recipient. +#[derive(Clone)] +struct Recipient { + name: String, + npub: String, + hue: usize, + /// Recipient relay hints (nprofile / NIP-05 resolution), extra delivery + /// targets for a recipient whose kind 10050 isn't discoverable yet. + relay_hints: Vec, +} + +/// A recipient search hit shown as a tappable card. +#[derive(Clone)] +struct Candidate { + name: String, + npub: String, + hue: usize, + /// Known contact, resolved goblin handle, or has a published nostr + /// profile. Unverified = a syntactically valid key with no profile. + verified: bool, + /// Short provenance tag shown on the card ("on nostr", "@goblin.st", …). + tag: &'static str, + /// Relay hints carried by an nprofile or NIP-05 resolution. + relay_hints: Vec, +} + +/// Async network lookup result for the typed query. +enum LookupResult { + /// A resolved/verified identity (nip05 hit or kind-0 profile found). + Found(Candidate), + /// A syntactically valid key with no published profile. + Unverified(Candidate), + /// Nothing found for a handle/name. + NotFound(String), +} + +/// The send flow state. +pub struct SendFlow { + stage: Stage, + search: String, + recipient: Option, + amount: String, + note: String, + hold: HoldToSend, + error: Option, + /// Async lookup result for the current query, written by a worker. + lookup_slot: std::sync::Arc>>, + /// Network lookup in flight. + looking_up: bool, + /// The query the current network result/`looking_up` belongs to. + lookup_query: String, + /// egui time of the last search edit, for debounce. + input_changed_at: f64, + /// Network candidate from the last completed lookup (deduped into view). + net_candidate: Option, + /// Pending "pay an unverified key?" confirm gate. + confirm_unverified: Option, + /// Camera QR scanner when scanning for a recipient. + scan: Option, + /// Start scanning on the next recipient frame (entry from header icon). + start_scan: bool, + /// Which half of the scan-to-pay screen is showing (camera vs. own code). + scan_tab: ScanTab, + /// The scan-to-pay screen is open (gates camera + My Code). + scan_open: bool, + /// Request mode: issue an Invoice1 to the recipient (ask them to pay) rather + /// than sending them money. Reuses the recipient picker; no balance guard. + request: bool, + /// Set when the success screen's "Receipt" button is tapped: the host view + /// opens the receipt for the latest tx with this npub after the flow closes. + pub receipt_npub: Option, + /// Atomic amount (nanogrin) we last asked the wallet to price, so the review + /// page dispatches one `CalculateFee` per amount instead of every frame. + fee_requested_for: Option, + /// Draft note held while the editor modal is open, so Cancel discards it. + note_draft: String, +} + +impl Default for SendFlow { + fn default() -> Self { + Self { + stage: Stage::Recipient, + search: String::new(), + recipient: None, + amount: String::new(), + note: String::new(), + hold: HoldToSend::default(), + error: None, + lookup_slot: std::sync::Arc::new(std::sync::Mutex::new(None)), + looking_up: false, + lookup_query: String::new(), + input_changed_at: 0.0, + net_candidate: None, + confirm_unverified: None, + scan: None, + start_scan: false, + scan_tab: ScanTab::Scan, + scan_open: false, + request: false, + receipt_npub: None, + fee_requested_for: None, + note_draft: String::new(), + } + } +} + +impl SendFlow { + /// Pre-fill a contact and skip to amount entry. + pub fn prefill_contact(&mut self, name: String, npub: String) { + let hue = data::hue_of(&npub); + self.recipient = Some(Recipient { + name, + npub, + hue, + relay_hints: vec![], + }); + self.stage = Stage::Amount; + } + + /// Pre-fill the amount (Pay tab, amount-first): the flow starts at the + /// recipient picker and jumps straight to review once one is resolved. + pub fn prefill_amount(&mut self, amount: String) { + self.amount = amount; + self.stage = Stage::Recipient; + } + + /// Open the flow as a money REQUEST for `amount`: pick a recipient, then + /// issue them a grin Invoice1 over nostr (they approve to pay). The amount is + /// fixed up front, so resolving a recipient jumps straight to confirmation. + pub fn new_request(amount: String) -> Self { + Self { + request: true, + amount, + ..Self::default() + } + } + + /// Open the recipient picker with the QR scanner active (header entry). + pub fn request_scan(&mut self) { + self.start_scan = true; + self.stage = Stage::Recipient; + } + + /// Render the flow. Returns true when the flow is finished (close it). + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + avatars: &mut AvatarTextures, + ) -> bool { + let t = theme::tokens(); + let mut done = false; + // Note editor modal — floats above the soft keyboard with a dimmed + // backdrop (the GRIM Modal system), like the wallet-password modal. + if Modal::opened() == Some(NOTE_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, _modal, cb| { + self.note_modal_content(ui, cb); + }); + } + egui::CentralPanel::default() + .frame(egui::Frame { + fill: if self.stage == Stage::Success { + t.accent + } else { + t.bg + }, + inner_margin: egui::Margin { + left: (View::far_left_inset_margin(ui) + 12.0) as i8, + right: (View::get_right_inset() + 12.0) as i8, + top: (View::get_top_inset() + 12.0) as i8, + bottom: (View::get_bottom_inset() + 12.0) as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + w::centered_column( + ui, + crate::gui::views::Content::SIDE_PANEL_WIDTH * 1.2, + |ui| match self.stage { + Stage::Recipient => done = self.recipient_ui(ui, wallet, cb, avatars), + Stage::Amount => done = self.amount_ui(ui, wallet, avatars, cb), + Stage::Review => done = self.review_ui(ui, wallet, avatars), + Stage::Sending => self.sending_ui(ui, wallet), + Stage::Success => done = self.success_ui(ui), + Stage::Failed => done = self.failed_ui(ui, wallet), + }, + ); + }); + done + } + + /// Content of the note-editor modal: a focused text field + Cancel/Save. + fn note_modal_content(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let mut save = false; + let mut cancel = false; + ui.vertical_centered(|ui| { + ui.add_space(4.0); + let mut field = TextEdit::new(egui::Id::from(NOTE_MODAL).with("input")) + .focus(true) + .hint_text(t!("goblin.send.note_hint")); + field.ui(ui, &mut self.note_draft, cb); + if field.enter_pressed { + save = true; + } + ui.add_space(10.0); + }); + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("goblin.send.note_cancel"), + Colors::white_or_black(false), + || cancel = true, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button( + ui, + t!("goblin.send.note_save"), + Colors::white_or_black(false), + || save = true, + ); + }); + }); + ui.add_space(4.0); + if cancel { + Modal::close(); + } + if save { + self.note = self.note_draft.trim().to_string(); + Modal::close(); + } + } + + fn back_header(&self, ui: &mut egui::Ui, title: &str) -> bool { + let t = theme::tokens(); + let mut back = false; + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + ARROW_LEFT, + FontId::new(16.0, fonts::regular()), + t.text, + ); + back = resp.clicked(); + ui.add_space(12.0); + ui.label( + RichText::new(title) + .font(FontId::new(18.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(12.0); + back + } + + fn recipient_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + avatars: &mut AvatarTextures, + ) -> bool { + let t = theme::tokens(); + + // Header-icon entry opens the scan-to-pay screen on the camera tab. + if self.start_scan { + self.start_scan = false; + self.scan_open = true; + self.scan_tab = ScanTab::Scan; + } + + // Scan-to-pay screen: a Scan | My Code toggle over the camera or your own + // payment QR. Replaces the picker until closed. + if self.scan_open { + let title = if self.request { + t!("goblin.send.scan_to_request") + } else { + t!("goblin.send.scan_to_pay") + }; + if self.back_header(ui, &title) { + cb.stop_camera(); + self.scan = None; + self.scan_open = false; + return false; + } + let sel = if self.scan_tab == ScanTab::Scan { 0 } else { 1 }; + let (tab_scan, tab_my_code) = + (t!("goblin.send.tab_scan"), t!("goblin.send.tab_my_code")); + if let Some(i) = w::segmented(ui, &[&tab_scan, &tab_my_code], sel) { + self.scan_tab = if i == 0 { + ScanTab::Scan + } else { + ScanTab::MyCode + }; + } + ui.add_space(14.0); + match self.scan_tab { + ScanTab::Scan => { + // Keep the camera running on this tab. + if self.scan.is_none() { + cb.start_camera(); + self.scan = Some(CameraContent::default()); + } + self.scan_ui(ui, wallet, cb); + } + ScanTab::MyCode => { + // No camera needed while showing our own code. + if self.scan.is_some() { + cb.stop_camera(); + self.scan = None; + } + self.my_code_ui(ui, wallet, cb); + } + } + return false; + } + + let title = if self.request { + t!("goblin.send.request_from") + } else { + t!("goblin.send.send_to") + }; + if self.back_header(ui, &title) { + return true; + } + + // Search field: filled rounded box per the design, QR scan at right. + let mut search = self.search.clone(); + let mut open_scan = false; + egui::Frame { + fill: t.surface2, + corner_radius: eframe::epaint::CornerRadius::same(14), + inner_margin: egui::Margin::symmetric(14, 13), + ..Default::default() + } + .show(ui, |ui| { + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::Vec2::new(24.0, 42.0), egui::Sense::hover()); + ui.painter().text( + icon_rect.center(), + egui::Align2::CENTER_CENTER, + MAGNIFYING_GLASS, + FontId::new(22.0, fonts::regular()), + t.surface_text_dim, + ); + ui.add_space(8.0); + let mut te = TextEdit::new(egui::Id::from("send_search")) + .focus(false) + .hint_text(t!("goblin.send.search_hint")) + .text_color(t.surface_text) + .body() + .scan_qr(); + te.ui(ui, &mut search, cb); + // scan_qr() already starts the camera on tap. + open_scan = te.scan_pressed; + }); + }); + if open_scan { + // The field's scan_qr already started the camera; open the scan-to-pay + // screen on the Scan tab so the feed actually shows (the screen is gated + // on `scan_open`, not `scan`). + self.scan_open = true; + self.scan_tab = ScanTab::Scan; + self.scan = Some(CameraContent::default()); + self.error = None; + } + if search != self.search { + self.search = search; + self.error = None; + self.input_changed_at = ui.input(|i| i.time); + // New query — drop the stale network result until it re-resolves. + self.net_candidate = None; + } + ui.add_space(14.0); + + // The pay-an-unverified-key gate pre-empts the picker. + if let Some(cand) = self.confirm_unverified.clone() { + self.unverified_gate_ui(ui, &cand); + return false; + } + + // Drive the debounced network lookup + poll its result. + self.drive_lookup(wallet, ui.input(|i| i.time)); + + let query = self.search.trim().to_string(); + if query.is_empty() { + // Empty query → suggested recent peers, as before. + ui.label( + RichText::new(t!("goblin.send.suggested", "icon" => USERS)) + .font(fonts::kicker()) + .color(t.text_mute), + ); + ui.add_space(8.0); + let peers = recent_peers(wallet, 20); + let texs: Vec> = peers + .iter() + .map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name)) + .collect(); + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + if peers.is_empty() { + ui.add_space(20.0); + ui.label( + RichText::new(t!("goblin.send.no_contacts")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + } + for ((name, hue, npub), tex) in peers.into_iter().zip(texs.iter()) { + if w::activity_row( + ui, + &name, + &data::full_npub(&npub), + hue, + &npub, + "", + false, + false, + tex.as_ref(), + ) + .clicked() + { + self.pick(Candidate { + name, + npub, + hue, + verified: true, + tag: "", + relay_hints: vec![], + }); + } + } + }); + return false; + } + + // Type-ahead results: instant local matches + the network candidate. + let mut cands: Vec = search_contacts(wallet, &query, 6) + .into_iter() + .map(|(name, hue, npub)| Candidate { + name, + npub, + hue, + verified: true, + tag: "contact", + relay_hints: vec![], + }) + .collect(); + if let Some(net) = &self.net_candidate { + if !cands.iter().any(|c| c.npub == net.npub) { + cands.push(net.clone()); + } + } + let texs: Vec> = cands + .iter() + .map(|c| tex_for(avatars, ui.ctx(), wallet, &c.name)) + .collect(); + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for (c, tex) in cands.iter().zip(texs.iter()) { + let tag = if c.verified { + // Localize the human-worded provenance tags; domain / protocol + // tags (@goblin.st, nip-05) display verbatim. + let label = match c.tag { + "contact" => t!("goblin.send.tag_contact"), + "on nostr" => t!("goblin.send.tag_on_nostr"), + other => std::borrow::Cow::Borrowed(other), + }; + format!("✓ {}", label) + } else { + t!("goblin.send.no_profile").to_string() + }; + if w::activity_row( + ui, + &c.name, + &tag, + c.hue, + &c.npub, + "", + false, + false, + tex.as_ref(), + ) + .clicked() + { + self.pick(c.clone()); + } + } + if self.looking_up { + ui.add_space(10.0); + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.send.searching_nostr")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.ctx().request_repaint(); + } + if let Some(err) = &self.error { + ui.add_space(10.0); + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } + }); + false + } + + /// Select a candidate: verified ones go straight in; unverified keys hit + /// the confirm gate first. + fn pick(&mut self, cand: Candidate) { + if cand.verified { + self.recipient = Some(Recipient { + name: cand.name, + npub: cand.npub, + hue: cand.hue, + relay_hints: cand.relay_hints, + }); + let preset = amount_from_hr_string(&self.amount) + .map(|a| a > 0) + .unwrap_or(false); + self.stage = if preset { Stage::Review } else { Stage::Amount }; + } else { + self.confirm_unverified = Some(cand); + } + } + + /// "Pay an unverified key?" gate for a valid key with no nostr profile. + fn unverified_gate_ui(&mut self, ui: &mut egui::Ui, cand: &Candidate) { + let t = theme::tokens(); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(t!("goblin.send.unverified_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.send.unverified_body")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(8.0); + ui.label( + RichText::new(short_npub(&cand.npub)) + .font(FontId::new(12.0, fonts::mono())) + .color(t.surface_text_mute), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.send.keep_looking")).clicked() { + self.confirm_unverified = None; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action(ui, &t!("goblin.send.pay_anyway"), false).clicked() { + let mut c = cand.clone(); + c.verified = true; + self.confirm_unverified = None; + self.pick(c); + } + }, + ); + }); + }); + } + + /// Camera feed scanning for a recipient QR (npub / nostr: URI / @handle). + fn scan_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + let result = self.scan.as_mut().and_then(|cam| { + let res = cam.qr_scan_result(); + if res.is_none() { + cam.ui(ui, cb); + } + res + }); + if let Some(result) = result { + cb.stop_camera(); + self.scan = None; + // A captured code returns to the picker, where the resolved recipient + // (or an error) shows. + self.scan_open = false; + // Only plain text payloads can name a recipient — never echo + // seed words or slatepack contents into the search box. + match &result { + QrScanResult::Text(text) => { + let text = text.trim(); + let text = text + .strip_prefix("nostr:") + .or_else(|| text.strip_prefix("NOSTR:")) + .unwrap_or(text); + // Drop the scanned key into the search box; the picker's + // debounced lookup resolves + verifies it like typed input. + self.search = text.to_string(); + self.input_changed_at = ui.input(|i| i.time); + self.lookup_query.clear(); + self.net_candidate = None; + let _ = wallet; + } + _ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()), + } + return; + } + ui.add_space(14.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.send.scan_prompt")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + } + + /// "My Code" half of the scan-to-pay screen: our own payment QR (the nostr + /// nprofile) with the Goblin mark nested in the middle, for someone to scan + /// and pay us. Mirrors the Receive card, trimmed to the essentials. + fn my_code_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + let (handle, npub, nprofile) = wallet + .nostr_service() + .map(|s| { + let nip05 = s.identity.read().nip05.clone(); + let handle = nip05 + .map(|n| n.split('@').next().unwrap_or("").to_string()) + .unwrap_or_else(|| short_npub(&s.public_key().to_hex())); + (handle, s.npub(), s.nprofile()) + }) + .unwrap_or_else(|| ("—".to_string(), String::new(), String::new())); + w::card(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.label( + RichText::new(&handle) + .font(FontId::new(22.0, fonts::bold())) + .color(t.surface_text), + ); + ui.add_space(2.0); + ui.label( + RichText::new(t!("goblin.send.scan_to_pay_me")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(18.0); + let uri = format!("nostr:{}", nprofile); + w::qr_code(ui, &uri, 248.0); + ui.add_space(10.0); + }); + }); + + ui.add_space(12.0); + if w::big_action(ui, &t!("goblin.send.share_btn", "icon" => SHARE), false).clicked() { + // Share the full nostr identity (npub + relay hints), with the bare + // npub as a fallback line, via the platform's native share sheet. + let link = if nprofile.is_empty() { + npub.clone() + } else { + format!("nostr:{}", nprofile) + }; + let msg = t!( + "goblin.send.share_message", + "handle" => handle, + "link" => link, + "npub" => npub + ) + .to_string(); + cb.share_text(msg); + } + } + + /// Debounced network resolution: ~0.4s after the last keystroke, kick off + /// one lookup for the current query (npub/hex → nostr kind-0 profile; + /// name/@handle → goblin.st nip05). Poll the worker's result each frame. + fn drive_lookup(&mut self, wallet: &Wallet, now: f64) { + // Poll a finished lookup first. + if self.looking_up { + if let Some(res) = self.lookup_slot.lock().unwrap().take() { + self.looking_up = false; + match res { + LookupResult::Found(c) | LookupResult::Unverified(c) => { + self.net_candidate = Some(c); + self.error = None; + } + LookupResult::NotFound(label) => { + self.net_candidate = None; + self.error = + Some(t!("goblin.send.none_found", "label" => label).to_string()); + } + } + } else { + return; // still in flight + } + } + + let query = self.search.trim().to_string(); + if query.is_empty() { + self.lookup_query.clear(); + return; + } + // Debounce, and only resolve a given query once. + if query == self.lookup_query || now - self.input_changed_at < 0.4 { + return; + } + self.lookup_query = query.clone(); + self.error = None; + + use nostr_sdk::nips::nip19::Nip19Profile; + use nostr_sdk::{FromBech32, PublicKey}; + let key_input = query.strip_prefix("nostr:").unwrap_or(&query); + let (hex, key_hints) = if let Ok(pk) = PublicKey::from_bech32(key_input) { + (Some(pk.to_hex()), vec![]) + } else if let Ok(p) = Nip19Profile::from_bech32(key_input) { + // nprofile carries the recipient's own relay hints — the only + // routing info available for a fresh, undiscoverable key. + let hints = p.relays.iter().map(|r| r.to_string()).collect(); + (Some(p.public_key.to_hex()), hints) + } else if key_input.len() == 64 && key_input.chars().all(|c| c.is_ascii_hexdigit()) { + (Some(key_input.to_lowercase()), vec![]) + } else { + (None, vec![]) + }; + let slot = self.lookup_slot.clone(); + + if let Some(hex) = hex { + // Valid key → confirm it's a live identity via its kind-0 profile. + self.looking_up = true; + let service = wallet.nostr_service(); + let known = wallet.nostr_service().and_then(|s| { + s.store + .contact(&hex) + .map(|c| (display_name(&c), c.hue as usize)) + }); + std::thread::spawn(move || { + let hue = data::hue_of(&hex); + let profile = service.and_then(|s| s.fetch_profile_blocking(&hex, &key_hints)); + let res = match (known, profile) { + // Already a saved contact — trust it. + (Some((name, hue)), _) => LookupResult::Found(Candidate { + name, + npub: hex, + hue, + verified: true, + tag: "contact", + relay_hints: key_hints, + }), + (None, Some(p)) => { + let name = p + .nip05 + .as_deref() + .map(|n| n.split('@').next().unwrap_or("").to_string()) + .or(p.name) + .unwrap_or_else(|| short_npub(&hex)); + LookupResult::Found(Candidate { + name, + npub: hex, + hue, + verified: true, + tag: "on nostr", + relay_hints: key_hints, + }) + } + (None, None) => LookupResult::Unverified(Candidate { + name: short_npub(&hex), + npub: hex, + hue, + verified: false, + tag: "", + relay_hints: key_hints, + }), + }; + *slot.lock().unwrap() = Some(res); + }); + } else if let Some((name, domain)) = nip05::split_identifier(&query) { + // Name / @handle → goblin.st (or other) nip05 resolution. + self.looking_up = true; + let label = name.to_string(); + std::thread::spawn(move || { + let res = match resolve_nip05_blocking(&name, &domain) { + Some(r) => { + let hex = r.pubkey.to_hex(); + let home = domain == crate::nostr::nip05::home_domain(); + // Show the name without `@`; a foreign authority shows its + // domain (`name · domain`) so it can't masquerade as a home + // name. The NIP-05 root convention `_@domain` is just domain. + let display = if home { + name.to_string() + } else if name == "_" { + domain.clone() + } else { + format!("{name} · {domain}") + }; + LookupResult::Found(Candidate { + name: display, + npub: hex.clone(), + hue: data::hue_of(&hex), + // A successful NIP-05 resolution (home OR a named foreign + // authority) is verified — the user typed a specific + // handle and the domain is shown, so no bare-key gate. + verified: true, + tag: if home { "verified" } else { "nip-05" }, + // Resolution relay hints help deliver to a + // recipient whose kind 10050 we can't see. + relay_hints: r.relays, + }) + } + None => LookupResult::NotFound(label), + }; + *slot.lock().unwrap() = Some(res); + }); + } else { + self.error = Some(t!("goblin.send.enter_recipient").to_string()); + } + } + + fn amount_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + avatars: &mut AvatarTextures, + cb: &dyn PlatformCallbacks, + ) -> bool { + let t = theme::tokens(); + if self.back_header(ui, &t!("goblin.send.amount_title")) { + self.stage = Stage::Recipient; + return false; + } + let recipient = self.recipient.clone().unwrap(); + + // Block sends over the spendable balance: red amount + a message + an + // error buzz on tap, rather than letting it fail later at the node. + let spendable = wallet + .get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + let over = amount_from_hr_string(&self.amount) + .map(|a| a > spendable) + .unwrap_or(false); + + // Recipient chip, centered per the design. + let name_label = t!("goblin.send.to_name", "name" => recipient.name).to_string(); + let name_galley = ui.painter().layout_no_wrap( + name_label.clone(), + FontId::new(14.0, fonts::semibold()), + t.text, + ); + let chip_w = 28.0 + 8.0 + name_galley.size().x; + let chip_tex = tex_for(avatars, ui.ctx(), wallet, &recipient.name); + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - chip_w) / 2.0).max(0.0)); + w::avatar_any( + ui, + &recipient.name, + &recipient.npub, + 28.0, + recipient.hue, + chip_tex.as_ref(), + ); + ui.add_space(8.0); + ui.label( + RichText::new(name_label) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.text), + ); + }); + ui.add_space(20.0); + + // Big amount display. + let display = if self.amount.is_empty() { + "0".to_string() + } else { + self.amount.clone() + }; + if over { + w::amount_text_centered_ink(ui, &display, 64.0, t.neg, t.neg); + } else { + w::amount_text_centered(ui, &display, 64.0); + } + if let Ok(grin) = display.parse::() { + if let Some(preview) = super::pairing_preview(grin) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(preview) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + } + } + if over { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.send.not_enough")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.neg), + ); + }); + } + ui.add_space(16.0); + + let note_id = egui::Id::from("send_note"); + // Numpad / typed amount FIRST, then the note BELOW it. On mobile the soft + // keyboard for the note covers the bottom of the screen — keeping the pad + // above it means the pad stays visible and tappable, instead of being + // hidden behind the keyboard (the old order trapped you in the note). + let note_focused = ui.ctx().memory(|m| m.has_focus(note_id)); + if !View::is_desktop() { + if w::numpad(ui, &mut self.amount, cb) { + // Tapping the pad means you're back on the amount — drop the note's + // focus so its keyboard goes away. + ui.ctx().memory_mut(|m| m.surrender_focus(note_id)); + } + } else if !note_focused { + // Only consume keystrokes for the amount when the note field is + // not focused, so typing a note doesn't also edit the amount. + w::amount_typed_input(ui, &mut self.amount); + } + ui.add_space(12.0); + + // Note: opens a modal editor that floats above the soft keyboard with a + // dimmed backdrop, so the keyboard never covers it (works on every device). + let _ = note_id; + if self.note.trim().is_empty() { + if w::big_action(ui, &t!("goblin.send.add_note"), true).clicked() { + self.note_draft = self.note.clone(); + Modal::new(NOTE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.send.note_label")) + .show(); + } + } else { + // Show the saved note, with an Edit button to re-open the editor. + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(format!("\u{201C}{}\u{201D}", self.note.trim())) + .font(FontId::new(14.0, fonts::regular())) + .color(t.surface_text), + ); + }); + ui.add_space(8.0); + if w::big_action(ui, &t!("goblin.send.edit_note"), true).clicked() { + self.note_draft = self.note.clone(); + Modal::new(NOTE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.send.note_label")) + .show(); + } + } + ui.add_space(8.0); + + let valid = amount_from_hr_string(&self.amount) + .map(|a| a > 0) + .unwrap_or(false); + ui.add_enabled_ui(valid, |ui| { + if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() { + if over { + cb.vibrate_error(); + } else { + self.stage = Stage::Review; + } + } + }); + false + } + + fn review_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + avatars: &mut AvatarTextures, + ) -> bool { + let t = theme::tokens(); + let title = if self.request { + t!("goblin.send.confirm_request") + } else { + t!("goblin.send.review_title") + }; + if self.back_header(ui, &title) { + // Requests fix the amount on the Pay tab, so back returns to the + // recipient picker rather than the send-style amount step. + self.stage = if self.request { + Stage::Recipient + } else { + Stage::Amount + }; + return false; + } + let recipient = self.recipient.clone().unwrap(); + let amount = self.amount.clone(); + let hero_tex = tex_for(avatars, ui.ctx(), wallet, &recipient.name); + + // Over-balance guard: every path that reaches Review (including the + // Pay-tab / scan prefill that jumps straight here, skipping the amount + // step) must not offer a completable send for more than the spendable + // balance. Re-checked each frame so a mid-flow balance drop disables it. + let spendable = wallet + .get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + // Requests never spend our balance, so the guard does not apply to them. + let over = !self.request + && amount_from_hr_string(&amount) + .map(|a| a > spendable) + .unwrap_or(false); + + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.add_space(8.0); + let label = if self.request { + t!("goblin.send.requesting_from", "name" => recipient.name).to_string() + } else { + t!("goblin.send.youre_sending", "name" => recipient.name).to_string() + }; + // Centered avatar + caption. A long counterparty (a bare npub) wraps + // and stays centered instead of overflowing the card. + ui.vertical_centered(|ui| { + w::avatar_any( + ui, + &recipient.name, + &recipient.npub, + 40.0, + recipient.hue, + hero_tex.as_ref(), + ); + ui.add_space(6.0); + ui.label( + RichText::new(label) + .font(FontId::new(14.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.add_space(8.0); + w::amount_text_centered_ink(ui, &amount, 48.0, t.surface_text, t.surface_text_dim); + ui.add_space(8.0); + }); + ui.add_space(16.0); + + let from_to = if self.request { + t!("goblin.send.row_from") + } else { + t!("goblin.send.row_to") + }; + w::info_row(ui, &from_to, &recipient.name); + if !self.note.trim().is_empty() { + w::info_row( + ui, + &t!("goblin.send.row_note"), + &format!("\u{201C}{}\u{201D}", self.note.trim()), + ); + } + if self.request { + w::info_row( + ui, + &t!("goblin.send.row_they_pay"), + &t!("goblin.send.row_they_pay_val"), + ); + w::info_row( + ui, + &t!("goblin.send.row_delivery"), + &t!("goblin.send.row_delivery_val"), + ); + } else { + // Live network fee for this exact amount, priced by the wallet (one + // async CalculateFee per amount, like GRIM's send modal). Until the + // first result lands we show an ellipsis rather than a wrong number. + let amount_nano = amount_from_hr_string(&amount).unwrap_or(0); + if amount_nano > 0 && self.fee_requested_for != Some(amount_nano) { + self.fee_requested_for = Some(amount_nano); + wallet.task(WalletTask::CalculateFee(amount_nano, 0)); + } + let fee_val = match wallet.calculated_fee(amount_nano) { + Some(fee) => format!("{} {}", w::amount_str(fee), w::TSU), + None => { + // Result lands on a worker thread; poll until it does. + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(120)); + "…".to_string() + } + }; + w::info_row(ui, &t!("goblin.send.row_network_fee"), &fee_val); + w::info_row( + ui, + &t!("goblin.send.row_privacy"), + &t!("goblin.send.row_privacy_val"), + ); + w::info_row( + ui, + &t!("goblin.send.row_delivery"), + &t!("goblin.send.row_delivery_val"), + ); + } + ui.add_space(16.0); + + // Requests are not a spend: one tap sends the ask, no hold-to-confirm. + if self.request { + if w::big_action(ui, &t!("goblin.send.send_request_btn"), false).clicked() { + self.dispatch(wallet); + self.stage = Stage::Sending; + } + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.send.request_approve_hint")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + }); + return false; + } + + if over { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.send.not_enough")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.neg), + ); + }); + ui.add_space(8.0); + } + // Greyed out while over balance; the `&& !over` also refuses the send in + // case the hold widget ignores the disabled state. + ui.add_enabled_ui(!over, |ui| { + if self.hold.ui(ui, &t!("goblin.send.hold_to_send")) && !over { + self.dispatch(wallet); + self.stage = Stage::Sending; + } + }); + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(if over { + t!("goblin.send.lower_amount") + } else { + t!("goblin.send.hold_confirm_hint") + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + }); + false + } + + fn dispatch(&mut self, wallet: &Wallet) { + if let (Some(recipient), Ok(amount)) = + (&self.recipient, amount_from_hr_string(&self.amount)) + { + let note = if self.note.trim().is_empty() { + None + } else { + Some(self.note.trim().to_string()) + }; + // Clear any stale picker error so the failure screen shows the right + // message, and reset the send phase to Working so sending_ui waits for + // the real dispatch result rather than flipping to Success prematurely. + self.error = None; + if let Some(service) = wallet.nostr_service() { + service.set_send_phase(crate::nostr::send_phase::WORKING); + } + if self.request { + wallet.task(WalletTask::NostrRequest( + amount, + recipient.npub.clone(), + note, + recipient.relay_hints.clone(), + )); + } else { + wallet.task(WalletTask::NostrSend( + amount, + recipient.npub.clone(), + note, + recipient.relay_hints.clone(), + )); + } + } + } + + fn sending_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + ui.add_space(80.0); + ui.vertical_centered(|ui| { + View::big_loading_spinner(ui); + ui.add_space(16.0); + ui.label( + RichText::new(if self.request { + t!("goblin.send.requesting") + } else { + t!("goblin.send.sending") + }) + .font(FontId::new(18.0, fonts::semibold())) + .color(t.text), + ); + }); + // Advance only on the real dispatch result: the payment DM must have + // actually been sent (or failed) over the relay, not merely created. + let phase = wallet + .nostr_service() + .map(|s| s.send_phase()) + .unwrap_or(crate::nostr::send_phase::FAILED); + match phase { + crate::nostr::send_phase::SENT => self.stage = Stage::Success, + crate::nostr::send_phase::REQUEST_BLOCKED => { + let who = self + .recipient + .as_ref() + .map(|r| r.name.clone()) + .unwrap_or_else(|| t!("goblin.send.they").to_string()); + self.error = Some(t!("goblin.send.request_blocked", "who" => who).to_string()); + self.stage = Stage::Failed; + } + crate::nostr::send_phase::FAILED => { + // Surface the real reason (e.g. funds still confirming). + if self.error.is_none() { + self.error = wallet.nostr_service().and_then(|s| s.last_send_error()); + } + self.stage = Stage::Failed; + } + _ => {} + } + ui.ctx().request_repaint(); + } + + fn failed_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { + let t = theme::tokens(); + ui.add_space(80.0); + let mut done = false; + ui.vertical_centered(|ui| { + ui.label( + RichText::new(crate::gui::icons::WARNING_CIRCLE) + .font(FontId::new(56.0, fonts::regular())) + .color(t.neg), + ); + ui.add_space(16.0); + ui.label( + RichText::new(if self.request { + t!("goblin.send.failed_request_title") + } else { + t!("goblin.send.failed_send_title") + }) + .font(FontId::new(22.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(self.error.clone().unwrap_or_else(|| { + if self.request { + t!("goblin.send.failed_request_body").to_string() + } else { + t!("goblin.send.failed_send_body").to_string() + } + })) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.add_space(24.0); + if w::big_action(ui, &t!("goblin.send.try_again_btn"), false).clicked() { + self.dispatch(wallet); + self.stage = Stage::Sending; + } + ui.add_space(10.0); + if w::big_action(ui, &t!("goblin.send.close_btn"), true).clicked() { + done = true; + } + done + } + + fn success_ui(&mut self, ui: &mut egui::Ui) -> bool { + let t = theme::tokens(); + let recipient = self.recipient.clone().unwrap(); + ui.add_space(80.0); + ui.vertical_centered(|ui| { + // Mascot in an ink circle. + let (rect, _) = ui.allocate_exact_size(Vec2::splat(120.0), Sense::hover()); + ui.painter() + .circle_filled(rect.center(), 60.0, t.accent_ink); + let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg")) + .tint(t.accent) + .fit_to_exact_size(Vec2::splat(72.0)); + img.paint_at( + ui, + egui::Rect::from_center_size(rect.center(), Vec2::splat(72.0)), + ); + ui.add_space(24.0); + ui.label( + RichText::new(if self.request { t!("goblin.send.success.requested") } else { t!("goblin.send.success.sent") }) + .font(FontId::new(34.0, fonts::bold())) + .color(t.accent_ink), + ); + ui.add_space(8.0); + // Amount + ツ as one layout job so vertical_centered centers it exactly, + // independent of the number's width (a fixed offset drifts off-center). + let mut job = egui::text::LayoutJob::default(); + job.append( + &self.amount, + 0.0, + egui::text::TextFormat { + font_id: FontId::new(40.0, fonts::mono_semibold()), + color: t.accent_ink, + ..Default::default() + }, + ); + job.append( + w::TSU, + 0.0, + egui::text::TextFormat { + font_id: FontId::new(20.0, fonts::medium()), + color: t.accent_ink, + valign: Align::BOTTOM, + ..Default::default() + }, + ); + ui.label(job); + ui.add_space(8.0); + ui.label( + RichText::new(t!( + "goblin.send.success.subtitle", + "dir" => if self.request { t!("goblin.send.success.from") } else { t!("goblin.send.success.to") }, + "who" => recipient.name + )) + .font(FontId::new(15.0, fonts::regular())) + .color(t.accent_ink.gamma_multiply(0.7)), + ); + }); + + let mut done = false; + let is_request = self.request; + let mut want_receipt = false; + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + ui.add_space(20.0); + let (rect, resp) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), 56.0), Sense::click()); + ui.painter().rect( + rect, + eframe::epaint::CornerRadius::same(14), + t.accent_ink, + eframe::epaint::Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + t!("goblin.send.success.done_btn"), + FontId::new(17.0, fonts::semibold()), + t.accent, + ); + if resp.clicked() { + done = true; + } + // Receipt (secondary; sends only) — sits above Done in bottom-up. + if !is_request { + ui.add_space(10.0); + let (r2, resp2) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), 56.0), Sense::click()); + ui.painter().rect( + r2, + eframe::epaint::CornerRadius::same(14), + egui::Color32::TRANSPARENT, + eframe::epaint::Stroke::new(1.5, t.accent_ink), + egui::StrokeKind::Inside, + ); + ui.painter().text( + r2.center(), + egui::Align2::CENTER_CENTER, + t!("goblin.send.success.receipt_btn"), + FontId::new(17.0, fonts::semibold()), + t.accent_ink, + ); + if resp2.clicked() { + want_receipt = true; + } + } + }); + if want_receipt { + self.receipt_npub = Some(recipient.npub.clone()); + done = true; + } + done + } +} + +/// Resolve a NIP-05 identifier on a short-lived runtime (blocking the UI +/// briefly is acceptable for an explicit "find recipient" action). +fn resolve_nip05_blocking(name: &str, domain: &str) -> Option { + let name = name.to_string(); + let domain = domain.to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok()?; + rt.block_on(nip05::resolve(&name, &domain)) + }) + .join() + .ok() + .flatten() +} diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs new file mode 100644 index 00000000..158b10fc --- /dev/null +++ b/src/gui/views/goblin/widgets.rs @@ -0,0 +1,991 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Reusable Goblin design widgets: avatars, amounts, buttons, rows, chips. + +use eframe::epaint::{CornerRadius, FontId, Stroke}; +use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, Vec2}; + +use crate::gui::theme::{self, fonts}; + +/// Currency mark for grin amounts. +pub const TSU: &str = "ツ"; + +/// Format atomic grin units to a trimmed human string (no unit). +pub fn amount_str(atomic: u64) -> String { + grin_core::core::amount_to_hr_string(atomic, true) +} + +/// Draw a colored avatar puck with the contact initial. +pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let (bg, ink) = theme::avatar_pair(hue); + ui.painter().circle_filled(rect.center(), size / 2.0, bg); + // First letter of the name — never the @ prefix or other decoration. + let initial = name + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "?".to_string()); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + initial, + FontId::new(size * 0.42, fonts::bold()), + ink, + ); + resp +} + +/// A custom-picture avatar: the texture drawn in a circle. +pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let rounding = eframe::epaint::CornerRadius::same((size / 2.0) as u8); + egui::Image::new(tex) + .corner_radius(rounding) + .fit_to_exact_size(Vec2::splat(size)) + .paint_at(ui, rect); + resp +} + +/// Deterministic gradient avatar (a pubkey-seeded two-tone tile with the Grin +/// mark on top) — the fallback for anonymous nostr users. `id` is the npub or +/// hex pubkey; the image is a pure function of it, so the same key always draws +/// the same avatar (see [`super::identicon`]). Cached per-pubkey by egui. +pub fn gradient_avatar(ui: &mut Ui, id: &str, size: f32) -> Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let hex = super::identicon::to_hex_seed(id); + // Rasterize at 2x for crispness; egui caches the texture by the `uri`, so the + // SVG is generated/rasterized once per pubkey regardless of frames or size. + let svg = super::identicon::gradient_avatar_svg(&hex, (size * 2.0) as u32, ""); + let uri = format!("bytes://gobavatar-{}-{}.svg", hex, size as u32); + egui::Image::new(egui::ImageSource::Bytes { + uri: uri.into(), + bytes: svg.into_bytes().into(), + }) + .corner_radius(CornerRadius::same((size / 2.0) as u8)) + .fit_to_exact_size(Vec2::splat(size)) + .paint_at(ui, rect); + resp +} + +/// A named user's avatar: the same pubkey-seeded gradient background as +/// [`gradient_avatar`], but with the person's initial painted on top (white with +/// a faint dark shadow for legibility on any hue) instead of the Grin mark. `id` +/// seeds the gradient; `name` supplies the letter. +pub fn gradient_letter_avatar(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let hex = super::identicon::to_hex_seed(id); + let svg = super::identicon::gradient_bg_svg(&hex, (size * 2.0) as u32); + let uri = format!("bytes://gobavatarbg-{}-{}.svg", hex, size as u32); + egui::Image::new(egui::ImageSource::Bytes { + uri: uri.into(), + bytes: svg.into_bytes().into(), + }) + .corner_radius(CornerRadius::same((size / 2.0) as u8)) + .fit_to_exact_size(Vec2::splat(size)) + .paint_at(ui, rect); + // Initial — first alphanumeric of the name, never the @ prefix. + let initial = name + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "?".to_string()); + let font = FontId::new(size * 0.46, fonts::bold()); + let c = rect.center(); + ui.painter().text( + c + Vec2::splat(size * 0.03), + egui::Align2::CENTER_CENTER, + &initial, + font.clone(), + Color32::from_black_alpha(80), + ); + ui.painter().text( + c, + egui::Align2::CENTER_CENTER, + &initial, + font, + Color32::from_rgb(0xFA, 0xFA, 0xF7), + ); + resp +} + +/// Picture avatar when a texture exists; otherwise the deterministic +/// pubkey-seeded gradient: with the Grin mark for an anonymous key (display name +/// is an `npub…`), or with the person's initial for a named contact/@handle. A +/// flat lettered tile is the last resort when no pubkey is known. `id` is the +/// npub/hex used to seed the gradient. +pub fn avatar_any( + ui: &mut Ui, + name: &str, + id: &str, + size: f32, + hue: usize, + tex: Option<&egui::TextureHandle>, +) -> Response { + match tex { + Some(t) => avatar_tex(ui, t, size), + None if name.starts_with("npub") && !id.is_empty() => gradient_avatar(ui, id, size), + None if !id.is_empty() => gradient_letter_avatar(ui, id, name, size), + None => avatar(ui, name, size, hue), + } +} + +/// Draw a balance/amount: big bold number + smaller ツ mark, tight. +/// Geist (sans) per the design; mono is reserved for kernel/block ids. +pub fn amount_text(ui: &mut Ui, value: &str, size: f32) { + let t = theme::tokens(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label( + RichText::new(value) + .font(FontId::new(size, fonts::bold())) + .color(t.text), + ); + ui.add_space(1.0); + ui.label( + RichText::new(TSU) + .font(FontId::new(size * 0.4, fonts::medium())) + .color(t.text_dim), + ); + }); +} + +/// Like [`amount_text`] but centered in the available width. +pub fn amount_text_centered(ui: &mut Ui, value: &str, size: f32) { + let t = theme::tokens(); + amount_text_centered_ink(ui, value, size, t.text, t.text_dim); +} + +/// Centered amount with explicit inks, for drawing on card surfaces. +pub fn amount_text_centered_ink( + ui: &mut Ui, + value: &str, + size: f32, + num_ink: Color32, + mark_ink: Color32, +) { + amount_text_centered_shifted(ui, value, size, num_ink, mark_ink, 0.0); +} + +/// Like [`amount_text_centered_ink`] but nudged horizontally by `dx` pixels — the +/// hook for the "can't pay that" shake on the Pay screen. +pub fn amount_text_centered_shifted( + ui: &mut Ui, + value: &str, + size: f32, + num_ink: Color32, + mark_ink: Color32, + dx: f32, +) { + let avail = ui.available_width(); + let measure = |ui: &Ui, sz: f32| -> f32 { + let num = + ui.painter() + .layout_no_wrap(value.to_string(), FontId::new(sz, fonts::bold()), num_ink); + let mark = ui.painter().layout_no_wrap( + TSU.to_string(), + FontId::new(sz * 0.46, fonts::semibold()), + mark_ink, + ); + num.size().x + 1.0 + mark.size().x + }; + // Shrink to fit: a long balance (e.g. 0.46520721ツ) must not run off the + // edge. Glyph width is ~linear in font size, so scale down to the available + // width with a small margin and a sane floor. + let mut size = size; + let total0 = measure(ui, size); + if total0 > avail && total0 > 1.0 { + size = (size * (avail / total0) * 0.97).clamp(14.0, size); + } + let total = measure(ui, size); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(((ui.available_width() - total) / 2.0 + dx).max(0.0)); + ui.label( + RichText::new(value) + .font(FontId::new(size, fonts::bold())) + .color(num_ink), + ); + ui.add_space(1.0); + ui.label( + RichText::new(TSU) + .font(FontId::new(size * 0.46, fonts::semibold())) + .color(mark_ink), + ); + }); +} + +/// An uppercase letterspaced kicker label. +pub fn kicker(ui: &mut Ui, text: &str) { + let t = theme::tokens(); + ui.label( + RichText::new(text.to_uppercase()) + .font(fonts::kicker()) + .color(t.text_mute), + ); +} + +/// A Cash-App-style on/off switch. Yellow (brand accent) when on, neutral track +/// when off. Returns the response — the caller flips the bound state on click. +pub fn toggle(ui: &mut Ui, on: bool) -> Response { + let t = theme::tokens(); + let (rect, resp) = ui.allocate_exact_size(Vec2::new(46.0, 28.0), Sense::click()); + let track = if on { t.accent } else { t.surface2 }; + ui.painter() + .rect_filled(rect, CornerRadius::same(14), track); + let knob_r = 11.0; + let knob_x = if on { + rect.right() - knob_r - 3.0 + } else { + rect.left() + knob_r + 3.0 + }; + let knob = if on { + t.accent_ink + } else { + t.surface_text_mute + }; + ui.painter() + .circle_filled(egui::pos2(knob_x, rect.center().y), knob_r, knob); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A segmented control (e.g. `["Scan", "My Code"]`). Highlights `selected`; +/// returns `Some(i)` when a different segment is tapped. +pub fn segmented(ui: &mut Ui, labels: &[&str], selected: usize) -> Option { + let t = theme::tokens(); + let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::hover()); + ui.painter() + .rect_filled(rect, CornerRadius::same(22), t.surface2); + let inner = rect.shrink(4.0); + let seg_w = inner.width() / labels.len().max(1) as f32; + let mut clicked = None; + for (i, label) in labels.iter().enumerate() { + let seg = egui::Rect::from_min_size( + inner.min + Vec2::new(i as f32 * seg_w, 0.0), + Vec2::new(seg_w, inner.height()), + ); + let resp = ui.interact(seg, ui.id().with(("seg", i)), Sense::click()); + let on = i == selected; + if on { + ui.painter() + .rect_filled(seg, CornerRadius::same(18), t.accent); + } + ui.painter().text( + seg.center(), + egui::Align2::CENTER_CENTER, + *label, + FontId::new( + 15.0, + if on { + fonts::semibold() + } else { + fonts::regular() + }, + ), + if on { t.accent_ink } else { t.surface_text_dim }, + ); + if resp.clicked() && !on { + clicked = Some(i); + } + resp.on_hover_cursor(egui::CursorIcon::PointingHand); + } + clicked +} + +/// Big primary/secondary action button (56px, radius 14). +pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response { + let t = theme::tokens(); + let desired = Vec2::new(ui.available_width(), 56.0); + let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); + let (fill, ink, stroke) = if secondary { + (Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line)) + } else { + (t.accent, t.accent_ink, Stroke::NONE) + }; + let visual_fill = if resp.hovered() && !secondary { + t.accent_dark + } else { + fill + }; + ui.painter().rect( + rect, + CornerRadius::same(14), + visual_fill, + stroke, + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(17.0, fonts::semibold()), + ink, + ); + resp +} + +/// Secondary big action drawn on a card surface: same shape as +/// [`big_action`], but the label uses on-surface text so it stays readable +/// on the yellow theme's dark cards. +pub fn big_action_on_card(ui: &mut Ui, label: &str) -> Response { + let t = theme::tokens(); + let desired = Vec2::new(ui.available_width(), 56.0); + let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); + ui.painter().rect( + rect, + CornerRadius::same(14), + Color32::TRANSPARENT, + Stroke::new(1.5, t.line), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(17.0, fonts::semibold()), + t.surface_text, + ); + resp +} + +/// Like [`big_action_on_card`] with an explicit label ink (danger actions). +pub fn big_action_on_card_ink(ui: &mut Ui, label: &str, ink: Color32) -> Response { + let t = theme::tokens(); + let desired = Vec2::new(ui.available_width(), 44.0); + let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); + ui.painter().rect( + rect, + CornerRadius::same(14), + Color32::TRANSPARENT, + Stroke::new(1.5, t.line), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(15.0, fonts::semibold()), + ink, + ); + resp +} + +/// A full-width outlined action with an icon to the left of its label, bordered +/// in a tint of `ink` (so it reads "around the same color" as the text). Used +/// for the wallet-management cluster at the foot of Settings — switch / lock / +/// advanced — where each action stands on its own rather than in a card. +pub fn outlined_icon_action(ui: &mut Ui, icon: &str, label: &str, ink: Color32) -> Response { + let desired = Vec2::new(ui.available_width(), 50.0); + let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); + let border = ink.gamma_multiply(if resp.hovered() { 0.9 } else { 0.55 }); + let fill = if resp.hovered() { + ink.gamma_multiply(0.10) + } else { + Color32::TRANSPARENT + }; + ui.painter().rect( + rect, + CornerRadius::same(14), + fill, + Stroke::new(1.5, border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.left_center() + Vec2::new(18.0, 0.0), + egui::Align2::LEFT_CENTER, + icon, + FontId::new(18.0, fonts::regular()), + ink, + ); + ui.painter().text( + rect.left_center() + Vec2::new(46.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + FontId::new(15.0, fonts::semibold()), + ink, + ); + resp +} + +/// A pill/chip; returns the click response. `active` paints it inverted. +pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response { + let t = theme::tokens(); + let galley = ui.painter().layout_no_wrap( + label.to_string(), + FontId::new(13.0, fonts::semibold()), + if active { t.bg } else { t.surface_text }, + ); + let pad = Vec2::new(14.0, 8.0); + let size = galley.size() + pad * 2.0; + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let fill = if active { t.text } else { t.surface2 }; + ui.painter().rect( + rect, + CornerRadius::same(255), + fill, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().galley( + rect.center() - galley.size() / 2.0, + galley, + if active { t.bg } else { t.surface_text }, + ); + resp +} + +/// An outline pill chip (transparent fill, line border) per the design's +/// amount quick-select row. +pub fn chip_outline(ui: &mut Ui, label: &str) -> Response { + let t = theme::tokens(); + let galley = ui.painter().layout_no_wrap( + label.to_string(), + FontId::new(13.0, fonts::semibold()), + t.text, + ); + let pad = Vec2::new(14.0, 8.0); + let size = galley.size() + pad * 2.0; + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + ui.painter().rect( + rect, + CornerRadius::same(255), + Color32::TRANSPARENT, + Stroke::new(1.0, t.line), + egui::StrokeKind::Inside, + ); + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, t.text); + resp +} + +/// Paint a QR code for `text` with the goblin mark centered. Always dark modules +/// on a white plate, whatever the theme — inverted codes fail to decode in many +/// scanners. Encoded synchronously each frame; modules are plain painter rects. +pub fn qr_code(ui: &mut Ui, text: &str, size: f32) { + let plate = Color32::WHITE; + let ink = Color32::from_rgb(0x0E, 0x0E, 0x0C); + // High error correction tolerates the center mark covering modules. + let Ok(qr) = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::High) else { + return; + }; + let pad = (size * 0.05).max(8.0); + let (outer, _) = ui.allocate_exact_size(Vec2::splat(size + pad * 2.0), Sense::hover()); + ui.painter() + .rect_filled(outer, CornerRadius::same(16), plate); + let rect = outer.shrink(pad); + let n = qr.size(); + let cell = size / n as f32; + // Full cells, no inter-module gap: at receive-card density (~4.5px cells) even + // a 0.5px gap fragments the finder patterns and scanners fail. Round corners + // only when cells are large enough that the notching can't matter. + let radius = if cell >= 6.0 { (cell * 0.3) as u8 } else { 0 }; + for y in 0..n { + for x in 0..n { + if qr.get_module(x, y) { + let min = rect.min + Vec2::new(x as f32 * cell, y as f32 * cell); + ui.painter().rect_filled( + egui::Rect::from_min_size(min, Vec2::splat(cell)), + CornerRadius::same(radius), + ink, + ); + } + } + } + // Goblin mark on a yellow backing square in the center, 19% footprint (larger + // obscures too many modules for a reliable decode). Yellow reads as "light" to + // a scanner like white, so the covered center is recovered by the High ECC. + let t = theme::tokens(); + let backing = size * 0.19; + let b_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing)); + ui.painter() + .rect_filled(b_rect, CornerRadius::same((backing * 0.18) as u8), t.accent); + let m_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing * 0.72)); + egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg")) + .tint(t.accent_ink) + .fit_to_exact_size(m_rect.size()) + .paint_at(ui, m_rect); +} + +/// A filled input well for a text field sitting on a card, so the field +/// reads as a field: frameless edits on the card fill are invisible. +pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) { + let t = theme::tokens(); + egui::Frame { + fill: t.surface2, + stroke: Stroke::new(1.0, t.line), + corner_radius: CornerRadius::same(10), + inner_margin: egui::Margin::symmetric(12, 10), + ..Default::default() + } + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + content(ui); + }); +} + +/// A balance hero block: kicker, big number + ツ, optional fiat line. +pub fn balance_hero(ui: &mut Ui, total: u64, spendable: u64, fiat: Option<&str>, size: f32) { + let t = theme::tokens(); + // Headline is the TOTAL the wallet holds — same number GRIM shows — so a + // wallet mid-confirmation doesn't look empty. + ui.vertical_centered(|ui| kicker(ui, "Balance")); + ui.add_space(6.0); + amount_text_centered(ui, &amount_str(total), size); + // When some of it can't be spent yet (a payment still confirming, ~10 blocks), + // say how much is available vs confirming so a failed send explains itself. + if total > spendable { + let confirming = total - spendable; + ui.add_space(4.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(format!( + "{}{} available · {}{} confirming", + amount_str(spendable), + TSU, + amount_str(confirming), + TSU + )) + .font(FontId::new(12.5, fonts::medium())) + .color(t.text_dim), + ); + }); + } + if let Some(fiat) = fiat { + ui.add_space(4.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(fiat) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + }); + } +} + +/// An activity row: avatar, title, subtitle, signed amount. +/// Returns the row click response. +pub fn activity_row( + ui: &mut Ui, + title: &str, + subtitle: &str, + hue: usize, + id: &str, + amount: &str, + incoming: bool, + system: bool, + tex: Option<&egui::TextureHandle>, +) -> Response { + let t = theme::tokens(); + let row_h = 60.0; + let (rect, resp) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click()); + let mut content = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect.shrink2(Vec2::new(0.0, 8.0))) + .layout(Layout::left_to_right(Align::Center)), + ); + content.horizontal(|ui| { + if system { + let (r, _) = ui.allocate_exact_size(Vec2::splat(40.0), Sense::hover()); + ui.painter().rect( + r, + CornerRadius::same(10), + t.surface2, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + crate::gui::icons::CUBE, + FontId::new(20.0, fonts::regular()), + t.text, + ); + } else { + avatar_any(ui, title, id, 40.0, hue, tex); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(2.0); + ui.add( + egui::Label::new( + RichText::new(title) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.text), + ) + .truncate(), + ); + // Single-line, truncated: keeps the fixed-height row tidy even when + // the subtitle is a long value (e.g. a full npub in the picker). + ui.add( + egui::Label::new( + RichText::new(subtitle) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ) + .truncate(), + ); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(amount) + .font(FontId::new(15.0, fonts::mono_semibold())) + .color(if incoming { t.pos } else { t.text }), + ); + }); + }); + // Divider. + let line_y = rect.bottom(); + ui.painter() + .hline(rect.left()..=rect.right(), line_y, Stroke::new(1.0, t.line)); + resp +} + +/// Section header used above grouped lists. +pub fn section_header(ui: &mut Ui, text: &str) { + ui.add_space(8.0); + kicker(ui, text); + ui.add_space(6.0); +} + +/// Draw a rounded surface card and run a closure inside it. +pub fn card(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + let t = theme::tokens(); + egui::Frame::new() + .fill(t.surface) + .stroke(Stroke::new(1.0, t.line)) + .corner_radius(CornerRadius::same(18)) + .inner_margin(16.0) + .show(ui, add_contents) + .inner +} + +/// A bordered rect helper for non-interactive value rows. +pub fn info_row(ui: &mut Ui, label: &str, value: &str) { + let t = theme::tokens(); + ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // Truncate so a long value (e.g. "Encrypted nostr DM over Nym") never + // runs past the edge or collides with the label on a narrow screen. + ui.add( + egui::Label::new( + RichText::new(value) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.text), + ) + .truncate(), + ); + }); + }); + ui.add_space(8.0); + ui.painter().hline( + ui.min_rect().left()..=ui.min_rect().right(), + ui.cursor().top(), + Stroke::new(1.0, t.line), + ); + ui.add_space(8.0); +} + +/// Draw a centered Send / Receive split. Returns (send, receive) clicks. +pub fn send_receive(ui: &mut Ui) -> (bool, bool) { + let t = theme::tokens(); + let mut send = false; + let mut receive = false; + let h = 60.0; + ui.horizontal(|ui| { + let w = (ui.available_width() - 10.0) / 2.0; + let (rs, resp_s) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click()); + ui.painter().rect( + rs, + CornerRadius::same(14), + if resp_s.hovered() { + t.accent_dark + } else { + t.accent + }, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + rs.center(), + egui::Align2::CENTER_CENTER, + format!("{} Send", crate::gui::icons::ARROW_UP), + FontId::new(16.0, fonts::semibold()), + t.accent_ink, + ); + send = resp_s.clicked(); + ui.add_space(10.0); + let (rr, resp_r) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click()); + let r_fill = if resp_r.hovered() { + t.hover + } else { + t.surface2 + }; + ui.painter().rect( + rr, + CornerRadius::same(14), + r_fill, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + rr.center(), + egui::Align2::CENTER_CENTER, + format!("{} Receive", crate::gui::icons::ARROW_DOWN), + FontId::new(16.0, fonts::semibold()), + theme::ink_for(r_fill), + ); + receive = resp_r.clicked(); + }); + (send, receive) +} + +/// A simple numeric keypad. Mutates `amount` string. Returns true if changed. +pub fn numpad( + ui: &mut Ui, + amount: &mut String, + cb: &dyn crate::gui::platform::PlatformCallbacks, +) -> bool { + let t = theme::tokens(); + let mut changed = false; + let keys = [ + ["1", "2", "3"], + ["4", "5", "6"], + ["7", "8", "9"], + [".", "0", "<"], + ]; + let key_h = 58.0; + let gap = 14.0; + // Center a fixed-width pad so the three columns line up directly under + // the centered amount above, on any width. Wider than before to give the + // columns more breathing room (payment-app-style). + let pad_w = ui.available_width().min(332.0); + let key_w = (pad_w - 2.0 * gap) / 3.0; + let side = ((ui.available_width() - pad_w) / 2.0).max(0.0); + // Spread the four rows toward the bottom when there's room (the Pay tab, + // which otherwise leaves a big empty gap), staying compact on dense + // screens (the send flow). Reserve space below for the action buttons and + // the floating tab bar. Clamped so it never stretches absurdly or overflows. + let reserve_below = 170.0; + let avail = (ui.available_height() - reserve_below).max(0.0); + let row_gap = ((avail - key_h * 4.0) / 3.0).clamp(6.0, 30.0); + for (ri, row) in keys.iter().enumerate() { + if ri > 0 { + ui.add_space(row_gap); + } + ui.horizontal(|ui| { + ui.add_space(side); + for (i, &k) in row.iter().enumerate() { + if i > 0 { + ui.add_space(gap); + } + let (rect, resp) = ui.allocate_exact_size(Vec2::new(key_w, key_h), Sense::click()); + let label = if k == "<" { + crate::gui::icons::BACKSPACE.to_string() + } else { + k.to_string() + }; + let col = if resp.hovered() { t.accent } else { t.text }; + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(30.0, fonts::medium()), + col, + ); + if resp.clicked() { + let before = amount.clone(); + apply_key(amount, k); + if *amount == before { + // A no-op key — a second '.', a '0' on a leading zero, the + // 9-decimal cap, or backspace on empty. Nudge with a short + // error haptic instead of silently doing nothing. + cb.vibrate_error(); + } else { + changed = true; + } + } + } + }); + } + changed +} + +/// Apply a numpad key to the amount string with validation. +/// Apply typed keyboard events (digits, '.', backspace) to an amount string, +/// for desktop where the on-screen numpad is hidden. +pub fn amount_typed_input(ui: &Ui, amount: &mut String) { + ui.input(|i| { + for ev in &i.events { + if let egui::Event::Text(txt) = ev { + for ch in txt.chars() { + if ch.is_ascii_digit() { + apply_key(amount, &ch.to_string()); + } else if ch == '.' { + apply_key(amount, "."); + } + } + } + if let egui::Event::Key { + key: egui::Key::Backspace, + pressed: true, + .. + } = ev + { + apply_key(amount, "<"); + } + } + }); +} + +pub fn apply_key(amount: &mut String, key: &str) { + match key { + "<" => { + amount.pop(); + } + "." => { + if !amount.contains('.') { + if amount.is_empty() { + amount.push('0'); + } + amount.push('.'); + } + } + d => { + // Limit to 9 decimals (grin precision). + if let Some(dot) = amount.find('.') { + if amount.len() - dot - 1 >= 9 { + return; + } + } + // Avoid leading zeros like "00". + if amount == "0" { + amount.clear(); + } + amount.push_str(d); + } + } +} + +/// Paint a full-rect background fill on the current panel. +pub fn fill_bg(ui: &Ui, color: Color32) { + let rect = ui.ctx().screen_rect(); + ui.painter().rect_filled(rect, CornerRadius::ZERO, color); +} + +/// Center a fixed-width column for narrow content on wide screens. +/// Hands the child the full remaining height: wrapping in `horizontal()` +/// would start the row a single line tall, so a `ScrollArea` inside would +/// clip everything below the first widget. +pub fn centered_column(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + // Keep a small side gutter so content sits close to the screen edges on + // phones (where `width` exceeds the available width) without running flush. + const MIN_SIDE_PAD: f32 = 8.0; + let avail = ui.available_width(); + let w = width.min(avail - MIN_SIDE_PAD * 2.0).max(0.0); + let margin = ((avail - w) / 2.0).max(MIN_SIDE_PAD); + let mut rect = ui.available_rect_before_wrap(); + rect.min.x += margin; + rect.max.x = rect.min.x + w; + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(Layout::top_down(Align::Min)), + ); + let result = add(&mut child); + ui.allocate_rect(child.min_rect(), Sense::hover()); + result +} + +/// Hold-to-send button: fills over `hold_secs`; returns true once on completion. +pub struct HoldToSend { + progress: f32, +} + +impl Default for HoldToSend { + fn default() -> Self { + Self { progress: 0.0 } + } +} + +impl HoldToSend { + pub fn ui(&mut self, ui: &mut Ui, label: &str) -> bool { + let t = theme::tokens(); + let (rect, resp) = ui.allocate_exact_size( + Vec2::new(ui.available_width(), 56.0), + Sense::click_and_drag(), + ); + // Background. + ui.painter().rect( + rect, + CornerRadius::same(14), + t.surface2, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + let held = resp.is_pointer_button_down_on() || resp.dragged(); + let dt = ui.input(|i| i.stable_dt).min(0.1); + if held { + self.progress = (self.progress + dt / 0.7).min(1.0); + ui.ctx().request_repaint(); + } else { + self.progress = (self.progress - dt / 0.3).max(0.0); + if self.progress > 0.0 { + ui.ctx().request_repaint(); + } + } + // Progress fill. + if self.progress > 0.0 { + let mut fill_rect = rect; + fill_rect.set_width(rect.width() * self.progress); + ui.painter().rect( + fill_rect, + CornerRadius::same(14), + t.accent, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + } + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(17.0, fonts::semibold()), + if self.progress > 0.5 { + t.accent_ink + } else { + theme::ink_for(t.surface2) + }, + ); + if self.progress >= 1.0 { + self.progress = 0.0; + return true; + } + false + } +} + +/// Shorten a long key/address for display (8…6). +pub fn short_key(key: &str) -> String { + if key.len() <= 16 { + return key.to_string(); + } + format!("{}…{}", &key[..8], &key[key.len() - 6..]) +} diff --git a/src/gui/views/input/edit.rs b/src/gui/views/input/edit.rs new file mode 100644 index 00000000..9e949c2e --- /dev/null +++ b/src/gui/views/input/edit.rs @@ -0,0 +1,515 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use egui::text_edit::TextEditState; +use egui::{Align, Layout, TextBuffer, TextStyle, ViewportCommand, Widget}; +use lazy_static::lazy_static; +use parking_lot::RwLock; +use std::sync::Arc; + +use crate::gui::Colors; +use crate::gui::icons::{CLIPBOARD_TEXT, COPY, EYE, EYE_SLASH, SCAN}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::input::keyboard::KeyboardContent; +use crate::gui::views::{KeyboardEvent, View}; + +/// Text input content. +pub struct TextEdit { + /// View identifier. + id: egui::Id, + /// Check if input is enabled or disabled. + enabled: bool, + /// Horizontal text centering is needed. + h_center: bool, + /// Focus is needed. + focus: bool, + /// Focus request was passed. + focus_request: bool, + /// Hide letters and draw button to show/hide letters. + password: bool, + /// Show copy button. + copy: bool, + /// Show paste button. + paste: bool, + /// Show button to scan QR code into text. + scan_qr: bool, + /// Scan button was pressed. + pub scan_pressed: bool, + /// Tab or Enter keys were pressed to focus on next line. + pub enter_pressed: bool, + /// Flag to enter only numbers. + numeric: bool, + /// Flag to not show soft keyboard. + no_soft_keyboard: bool, + /// Optional placeholder shown when empty. + hint: Option, + /// Optional text color override (defaults to the theme text color). + text_color: Option, + /// Use the body text style instead of the default heading. + body_font: bool, +} + +impl TextEdit { + /// Default height of [`egui::TextEdit`] view. + const TEXT_EDIT_HEIGHT: f32 = 42.0; + + pub fn new(id: egui::Id) -> Self { + Self { + id, + enabled: true, + h_center: false, + focus: true, + focus_request: false, + password: false, + copy: false, + paste: false, + scan_qr: false, + scan_pressed: false, + enter_pressed: false, + numeric: false, + // Goblin uses each platform's NATIVE input everywhere: the Android + // soft keyboard via JNI on Android, the physical keyboard on desktop. + // Upstream Grim only suppresses its own on-screen keyboard on Android + // (`is_android()`) and pops it on desktop — which looked out of place + // in Goblin's wallet flows and competed with physical typing. Suppress + // it on every platform; native text entry still works. + no_soft_keyboard: true, + hint: None, + text_color: None, + body_font: false, + } + } + + /// Draw text input. + pub fn ui(&mut self, ui: &mut egui::Ui, input: &mut String, cb: &dyn PlatformCallbacks) { + self.input_ui(ui, input, |_| {}, cb); + } + + /// Draw text input with additional buttons (right to left order). + pub fn custom_buttons_ui( + &mut self, + ui: &mut egui::Ui, + input: &mut String, + cb: &dyn PlatformCallbacks, + buttons_content: impl FnOnce(&mut egui::Ui), + ) { + self.input_ui(ui, input, buttons_content, cb); + } + + /// Draw text input content. + fn input_ui( + &mut self, + ui: &mut egui::Ui, + input: &mut String, + buttons_content: impl FnOnce(&mut egui::Ui), + cb: &dyn PlatformCallbacks, + ) { + let mut layout_rect = ui.available_rect_before_wrap(); + layout_rect.set_height(Self::TEXT_EDIT_HEIGHT); + ui.allocate_ui_with_layout( + layout_rect.size(), + Layout::right_to_left(Align::Max), + |ui| { + let mut hide_input = false; + if self.password { + let show_pass_id = egui::Id::new(self.id).with("_show_pass"); + hide_input = ui.data(|data| data.get_temp(show_pass_id)).unwrap_or(true); + // Draw button to show/hide current password. + let eye_icon = if hide_input { EYE } else { EYE_SLASH }; + View::button_ui( + ui, + eye_icon.to_string(), + Colors::white_or_black(false), + |ui| { + hide_input = !hide_input; + ui.data_mut(|data| { + data.insert_temp(show_pass_id, hide_input); + }); + }, + ); + ui.add_space(8.0); + } + + // Extra buttons content. + (buttons_content)(ui); + + // Setup copy button. + if self.copy { + let copy_icon = COPY.to_string(); + View::button(ui, copy_icon, Colors::white_or_black(false), || { + cb.copy_string_to_buffer(input.clone()); + }); + ui.add_space(8.0); + } + + // Setup paste button. + if self.paste { + let paste_icon = CLIPBOARD_TEXT.to_string(); + View::button(ui, paste_icon, Colors::white_or_black(false), || { + *input = cb.get_string_from_buffer(); + }); + ui.add_space(8.0); + } + + // Setup scan QR code button. + if self.scan_qr { + let scan_icon = SCAN.to_string(); + View::button(ui, scan_icon, Colors::white_or_black(false), || { + cb.start_camera(); + self.scan_pressed = true; + }); + ui.add_space(8.0); + } + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Min), |ui| { + // Setup text edit size. + let mut edit_rect = ui.available_rect_before_wrap(); + edit_rect.set_height(Self::TEXT_EDIT_HEIGHT); + + // Setup focused input value to avoid dismiss when click on keyboard. + let focused_input_id = egui::Id::new("focused_input_id"); + let focused = ui + .data(|data| data.get_temp(focused_input_id)) + .unwrap_or(egui::Id::new("")) + == self.id; + + // Show text edit. + let text_edit_resp = egui::TextEdit::singleline(input) + .hint_text(self.hint.clone().unwrap_or_default()) + .text_color(if !self.enabled { + Colors::inactive_text() + } else if let Some(c) = self.text_color { + c + } else { + Colors::text(false) + }) + .interactive(self.enabled) + .id(self.id) + .font(if self.body_font { + TextStyle::Body + } else { + TextStyle::Heading + }) + .min_size(edit_rect.size()) + .margin(if View::is_desktop() { + egui::Margin::symmetric(4, 2) + } else { + egui::Margin::symmetric(8, 8) + }) + .horizontal_align(if self.h_center { + Align::Center + } else { + Align::Min + }) + .vertical_align(Align::Center) + .password(hide_input) + .cursor_at_end(true) + .ui(ui); + + // Setup focus state. + let clicked = text_edit_resp.clicked(); + if !text_edit_resp.has_focus() + && (self.focus || self.focus_request || clicked || focused) + { + text_edit_resp.request_focus(); + } + + // Reset keyboard state for newly focused. + if clicked || self.focus_request { + ui.ctx() + .send_viewport_cmd(ViewportCommand::IMEAllowed(true)); + KeyboardContent::reset_window_state(); + } + + // Apply text from software input. + if text_edit_resp.has_focus() { + ui.data_mut(|data| { + data.insert_temp(focused_input_id, self.id); + }); + self.enter_pressed = self.on_soft_input(ui, self.id, false, input); + // Check Enter or Tab keys press. + if !self.focus_request { + if ui.ctx().input(|i| { + i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Tab) + }) { + self.enter_pressed = true; + } + } + if self.enter_pressed { + KeyboardContent::unshift(); + } + if !self.no_soft_keyboard { + KeyboardContent::default().window_ui(self.numeric, ui.ctx()); + } + } + }); + }, + ); + // Immediate repaint when input is open. + ui.ctx().request_repaint(); + } + + /// Apply soft keyboard input data to provided String, returns `true` if Enter was pressed. + fn on_soft_input( + &self, + ui: &mut egui::Ui, + id: egui::Id, + multiline: bool, + value: &mut String, + ) -> bool { + let event: Option = if is_android() { + let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write(); + w_input.take() + } else { + KeyboardContent::consume_event() + }; + + // Handle keyboard input event. + if let Some(e) = event { + let mut enter_pressed = false; + let mut state = TextEditState::load(ui.ctx(), id).unwrap(); + match state.cursor.char_range() { + None => {} + Some(range) => { + let mut r = range.clone(); + let mut index = r.primary.index; + + let selected = r.primary.index != r.secondary.index; + let start_select = + f32::min(r.primary.index as f32, r.secondary.index as f32) as usize; + let end_select = + f32::max(r.primary.index as f32, r.secondary.index as f32) as usize; + match e { + KeyboardEvent::TEXT(text) => { + if selected { + *value = { + let part1: String = + value.chars().skip(0).take(start_select).collect(); + let part2: String = value + .chars() + .skip(end_select) + .take(value.len() - end_select) + .collect(); + format!("{}{}{}", part1, text, part2) + }; + index = start_select + 1; + } else { + value.insert_text(text.as_str(), index); + index = index + 1; + } + } + KeyboardEvent::CLEAR => { + if selected { + *value = { + let part1: String = + value.chars().skip(0).take(start_select).collect(); + let part2: String = value + .chars() + .skip(end_select) + .take(value.len() - end_select) + .collect(); + format!("{}{}", part1, part2) + }; + index = start_select; + } else if index != 0 { + *value = { + let part1: String = + value.chars().skip(0).take(index - 1).collect(); + let part2: String = value + .chars() + .skip(index) + .take(value.len() - index) + .collect(); + format!("{}{}", part1, part2) + }; + index = index - 1; + } + } + KeyboardEvent::ENTER => { + if multiline { + value.insert_text("\n", index); + index = index + 1; + } else { + enter_pressed = true; + } + } + } + // Setup cursor index. + r.primary.index = index; + r.secondary.index = r.primary.index; + + state.cursor.set_char_range(Some(r)); + TextEditState::store(state, ui.ctx(), id); + } + } + return enter_pressed; + } + false + } + + /// Set cursor to the end of text. + pub fn cursor_to_end(&self, text_len: usize, ui: &mut egui::Ui) { + let mut state = TextEditState::load(ui.ctx(), self.id).unwrap(); + match state.cursor.char_range() { + None => {} + Some(range) => { + let mut r = range.clone(); + r.primary.index = text_len; + r.secondary.index = text_len; + state.cursor.set_char_range(Some(r)); + TextEditState::store(state, ui.ctx(), self.id); + } + } + } + + /// Disable input. + pub fn disable(mut self) -> Self { + self.enabled = false; + self + } + + /// Center text horizontally. + pub fn h_center(mut self) -> Self { + self.h_center = true; + self + } + + /// Enable or disable constant focus. + pub fn focus(mut self, focus: bool) -> Self { + self.focus = focus; + self + } + + /// Focus on field. + pub fn focus_request(&mut self) { + self.focus_request = true; + } + + /// Allow input of numbers only. + pub fn numeric(mut self) -> Self { + self.numeric = true; + self + } + + /// Hide letters and draw button to show/hide letters. + pub fn password(mut self) -> Self { + self.password = true; + self + } + + /// Show button to copy text. + pub fn copy(mut self) -> Self { + self.copy = true; + self + } + + /// Show button to paste text. + pub fn paste(mut self) -> Self { + self.paste = true; + self + } + + /// Show button to scan QR code to text. + pub fn scan_qr(mut self) -> Self { + self.scan_qr = true; + self.scan_pressed = false; + self + } + + /// Do not show soft keyboard for input. + pub fn no_soft_keyboard(mut self) -> Self { + self.no_soft_keyboard = true; + self + } + + /// Set placeholder text shown when the field is empty. + pub fn hint_text(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// Override the text color (e.g. to match an on-card theme token). + pub fn text_color(mut self, color: egui::Color32) -> Self { + self.text_color = Some(color); + self + } + + /// Render with the body text style instead of the default heading. + pub fn body(mut self) -> Self { + self.body_font = true; + self + } +} + +/// Check if current system is Android. +fn is_android() -> bool { + egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Android +} + +lazy_static! { + static ref LAST_SOFT_KEYBOARD_EVENT: Arc>> = + Arc::new(RwLock::new(None)); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Callback from Java code with last entered character from soft keyboard. +pub extern "C" fn Java_mw_gri_android_MainActivity_onTextInput( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + char: jni::sys::jstring, +) { + use jni::objects::JString; + + unsafe { + let j_obj = JString::from_raw(char); + let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap(); + match j_str.to_str() { + Ok(str) => { + let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write(); + *w_input = Some(KeyboardEvent::TEXT(str.to_string())); + } + Err(_) => {} + } + } +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Callback from Java code when Clear key was pressed at soft keyboard. +pub extern "C" fn Java_mw_gri_android_MainActivity_onClearInput( + _env: jni::JNIEnv, + _class: jni::objects::JObject, +) { + let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write(); + *w_input = Some(KeyboardEvent::CLEAR); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Callback from Java code when Enter key was pressed at soft keyboard. +pub extern "C" fn Java_mw_gri_android_MainActivity_onEnterInput( + _env: jni::JNIEnv, + _class: jni::objects::JObject, +) { + let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write(); + *w_input = Some(KeyboardEvent::ENTER); +} diff --git a/src/gui/views/input/keyboard.rs b/src/gui/views/input/keyboard.rs new file mode 100644 index 00000000..721ea04d --- /dev/null +++ b/src/gui/views/input/keyboard.rs @@ -0,0 +1,549 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use egui::{ + Align, Align2, Button, Color32, CursorIcon, Layout, Margin, Rect, Response, RichText, Sense, + Shadow, Vec2, Widget, +}; +use lazy_static::lazy_static; +use parking_lot::RwLock; +use std::string::ToString; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{ARROW_FAT_UP, BACKSPACE, GLOBE_SIMPLE, KEY_RETURN}; +use crate::gui::views::{KeyboardEvent, KeyboardLayout, KeyboardState, View}; + +lazy_static! { + /// Keyboard window state. + static ref WINDOW_STATE: Arc> = Arc::new( + RwLock::new(KeyboardState::default()) + ); +} + +/// Software keyboard content. +pub struct KeyboardContent { + /// Keyboard content state. + state: KeyboardState, +} + +impl Default for KeyboardContent { + fn default() -> Self { + Self { + state: KeyboardState::default(), + } + } +} + +impl KeyboardContent { + /// Maximum keyboard content width. + const MAX_WIDTH: f32 = 600.0; + /// Maximum numbers layout width. + const MAX_WIDTH_NUMBERS: f32 = 400.0; + + /// Keyboard window id. + pub const WINDOW_ID: &'static str = "soft_keyboard_window"; + + /// Draw keyboard content as separate [`Window`]. + pub fn window_ui(&mut self, numeric: bool, ctx: &egui::Context) { + let width = ctx.content_rect().width(); + let layer_id = egui::Window::new(Self::WINDOW_ID) + .title_bar(false) + .resizable(false) + .collapsible(false) + .min_width(width) + .default_width(width) + .anchor(Align2::CENTER_BOTTOM, Vec2::new(0.0, 0.0)) + .frame(egui::Frame { + shadow: Shadow { + offset: Default::default(), + blur: 30.0 as u8, + spread: 3.0 as u8, + color: Color32::from_black_alpha(32), + }, + inner_margin: Margin { + left: View::get_left_inset() as i8, + right: View::get_right_inset() as i8, + top: 1.0 as i8, + bottom: View::get_bottom_inset() as i8, + }, + fill: Colors::fill(), + ..Default::default() + }) + .show(ctx, |ui| { + ui.set_min_width(width); + // Setup state. + { + let r_state = WINDOW_STATE.read(); + self.state = (*r_state).clone(); + } + // Calculate content width. + let side_insets = View::get_left_inset() + View::get_right_inset(); + let available_width = width - side_insets; + let w = f32::min( + available_width, + if numeric { + Self::MAX_WIDTH_NUMBERS + } else { + Self::MAX_WIDTH + }, + ); + // Draw content. + View::max_width_ui(ui, w, |ui| { + self.ui(numeric, ui); + }); + // Save state. + let mut w_state = WINDOW_STATE.write(); + *w_state = self.state.clone(); + }) + .unwrap() + .response + .layer_id; + + // Always show keyboard above others windows. + ctx.move_to_top(layer_id); + } + + /// Draw keyboard content. + pub fn ui(&mut self, numeric: bool, ui: &mut egui::Ui) { + // Setup layout. + if numeric { + self.state.layout = Arc::new(KeyboardLayout::NUMBERS); + } else if *self.state.layout == KeyboardLayout::NUMBERS { + self.state.layout = Arc::new(KeyboardLayout::TEXT); + } + + // Setup spacing between buttons. + ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0); + // Setup vertical padding inside buttons. + ui.style_mut().spacing.button_padding = egui::vec2(0.0, if numeric { 12.0 } else { 10.0 }); + + // Draw input buttons. + let button_rect = match *self.state.layout { + KeyboardLayout::TEXT => self.text_ui(ui), + KeyboardLayout::SYMBOLS => self.symbols_ui(ui), + KeyboardLayout::NUMBERS => self.numbers_ui(ui), + }; + + // Draw bottom keyboard buttons. + let bottom_size = { + let mut r = button_rect.clone(); + r.set_width(ui.available_width()); + r.size() + }; + let button_width = ui.available_width() + / match *self.state.layout { + KeyboardLayout::TEXT => 11.0, + KeyboardLayout::SYMBOLS => 10.0, + KeyboardLayout::NUMBERS => 4.0, + }; + ui.allocate_ui_with_layout(bottom_size, Layout::right_to_left(Align::Center), |ui| { + match *self.state.layout { + KeyboardLayout::TEXT => { + // Enter key input. + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width * 2.0); + self.custom_button_ui( + KEY_RETURN.to_string(), + Colors::white_or_black(false), + Some(Colors::green()), + ui, + |_, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::ENTER)); + }, + ); + }); + // Custom input key. + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width); + self.input_button_ui("m3", true, ui); + }); + // Space key input. + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width * 5.0); + self.custom_button_ui( + " ".to_string(), + Colors::inactive_text(), + None, + ui, + |l, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::TEXT(l))); + }, + ); + }); + // Switch to english and back. + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width); + self.custom_button_ui( + GLOBE_SIMPLE.to_string(), + Colors::text_button(), + Some(Colors::fill_lite()), + ui, + |_, _| AppConfig::toggle_english_keyboard(), + ); + }); + // Switch to symbols layout. + self.custom_button_ui( + "!@ツ".to_string(), + Colors::text_button(), + Some(Colors::fill_lite()), + ui, + |_, c| { + c.state.layout = Arc::new(KeyboardLayout::SYMBOLS); + }, + ); + } + KeyboardLayout::SYMBOLS => { + // Enter key input. + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width * 2.0); + self.custom_button_ui( + KEY_RETURN.to_string(), + Colors::white_or_black(false), + Some(Colors::green()), + ui, + |_, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::ENTER)); + }, + ); + }); + // Custom input key. + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width); + self.input_button_ui("ツ", false, ui); + }); + // Space key input. + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width * 4.0); + self.custom_button_ui( + " ".to_string(), + Colors::inactive_text(), + None, + ui, + |l, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::TEXT(l))); + }, + ); + }); + // Switch to text layout. + let label = { + let q = t!("keyboard.q", locale = Self::input_locale().as_str()); + let w = t!("keyboard.w", locale = Self::input_locale().as_str()); + let e = t!("keyboard.e", locale = Self::input_locale().as_str()); + format!("{}{}{}", q, w, e).to_uppercase() + }; + self.custom_button_ui( + label, + Colors::text_button(), + Some(Colors::fill_lite()), + ui, + |_, c| { + c.state.layout = Arc::new(KeyboardLayout::TEXT); + }, + ); + } + KeyboardLayout::NUMBERS => { + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width * 2.0); + self.custom_button_ui( + KEY_RETURN.to_string(), + Colors::white_or_black(false), + Some(Colors::green()), + ui, + |_, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::ENTER)); + }, + ); + }); + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width); + self.input_button_ui("0", true, ui); + }); + ui.horizontal_centered(|ui| { + ui.set_max_width(button_width); + self.input_button_ui(".", false, ui); + }); + } + } + }); + } + + /// Draw numbers content returning button [`Rect`]. + fn numbers_ui(&mut self, ui: &mut egui::Ui) -> Rect { + let mut button_rect = ui.available_rect_before_wrap(); + let tl_0: Vec<&str> = vec!["1", "2", "3", "+"]; + ui.columns(tl_0.len(), |columns| { + for (index, s) in tl_0.iter().enumerate() { + let last = index == tl_0.len() - 1; + button_rect = self.input_button_ui(s, !last, &mut columns[index]); + } + }); + + let tl_1: Vec<&str> = vec!["4", "5", "6", ","]; + ui.columns(tl_1.len(), |columns| { + for (index, s) in tl_1.iter().enumerate() { + let last = index == tl_1.len() - 1; + self.input_button_ui(s, !last, &mut columns[index]); + } + }); + + let tl_2: Vec<&str> = vec!["7", "8", "9", BACKSPACE]; + ui.columns(tl_2.len(), |columns| { + for (index, s) in tl_2.iter().enumerate() { + if index == tl_2.len() - 1 { + self.custom_button_ui( + BACKSPACE.to_string(), + Colors::red(), + Some(Colors::fill_lite()), + &mut columns[index], + |_, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::CLEAR)); + }, + ); + } else { + self.input_button_ui(s, true, &mut columns[index]); + } + } + }); + + button_rect + } + + /// Draw text content returning button [`Rect`]. + fn text_ui(&mut self, ui: &mut egui::Ui) -> Rect { + let mut button_rect = ui.available_rect_before_wrap(); + let tl_0: Vec<&str> = vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "01"]; + ui.columns(tl_0.len(), |columns| { + for (index, s) in tl_0.iter().enumerate() { + button_rect = self.input_button_ui(s, true, &mut columns[index]); + } + }); + + let tl_1: Vec<&str> = vec!["q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "p1"]; + ui.columns(tl_1.len(), |columns| { + for (index, s) in tl_1.iter().enumerate() { + self.input_button_ui(s, true, &mut columns[index]); + } + }); + + let tl_2: Vec<&str> = vec!["a", "s", "d", "f", "g", "h", "j", "k", "l", "l1", "l2"]; + ui.columns(tl_2.len(), |columns| { + for (index, s) in tl_2.iter().enumerate() { + self.input_button_ui(s, true, &mut columns[index]); + } + }); + + let tl_3: Vec<&str> = vec![ + ARROW_FAT_UP, + "z", + "x", + "c", + "v", + "b", + "n", + "m", + "m1", + "m2", + BACKSPACE, + ]; + ui.columns(tl_3.len(), |columns| { + for (index, s) in tl_3.iter().enumerate() { + if index == 0 { + let shift = self.state.shift.load(Ordering::Relaxed); + let color = if shift { + Colors::yellow_dark() + } else { + Colors::inactive_text() + }; + self.custom_button_ui( + ARROW_FAT_UP.to_string(), + color, + Some(Colors::fill_lite()), + &mut columns[index], + |_, c| { + c.state.shift.store(!shift, Ordering::Relaxed); + }, + ); + } else if index == tl_3.len() - 1 { + self.custom_button_ui( + BACKSPACE.to_string(), + Colors::red(), + Some(Colors::fill_lite()), + &mut columns[index], + |_, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::CLEAR)); + }, + ); + } else { + self.input_button_ui(s, true, &mut columns[index]); + } + } + }); + + button_rect + } + + /// Draw symbols content returning button [`Rect`]. + fn symbols_ui(&mut self, ui: &mut egui::Ui) -> Rect { + let mut button_rect = ui.available_rect_before_wrap(); + let tl_0: Vec<&str> = vec!["[", "]", "{", "}", "#", "%", "^", "*", "+", "="]; + ui.columns(tl_0.len(), |columns| { + for (index, s) in tl_0.iter().enumerate() { + button_rect = self.input_button_ui(s, false, &mut columns[index]); + } + }); + + let tl_1: Vec<&str> = vec!["_", "\\", "|", "~", "<", ">", "№", "√", "π", "•"]; + ui.columns(tl_1.len(), |columns| { + for (index, s) in tl_1.iter().enumerate() { + self.input_button_ui(s, false, &mut columns[index]); + } + }); + + let tl_2: Vec<&str> = vec!["-", "/", ":", ";", "(", ")", "`", "&", "@", "\""]; + ui.columns(tl_2.len(), |columns| { + for (index, s) in tl_2.iter().enumerate() { + self.input_button_ui(s, false, &mut columns[index]); + } + }); + + let tl_3: Vec<&str> = vec![".", ",", "?", "!", "€", "£", "¥", "$", "¢", BACKSPACE]; + ui.columns(tl_3.len(), |columns| { + for (index, s) in tl_3.iter().enumerate() { + if index == tl_3.len() - 1 { + self.custom_button_ui( + BACKSPACE.to_string(), + Colors::red(), + Some(Colors::fill_lite()), + &mut columns[index], + |_, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::CLEAR)); + }, + ); + } else { + self.input_button_ui(s, false, &mut columns[index]); + } + } + }); + + button_rect + } + + /// Draw custom keyboard button. + fn custom_button_ui( + &mut self, + s: String, + color: Color32, + bg: Option, + ui: &mut egui::Ui, + cb: impl FnOnce(String, &mut KeyboardContent), + ) -> Response { + ui.vertical_centered_justified(|ui| { + // Disable expansion on click/hover. + ui.style_mut().visuals.widgets.hovered.expansion = 0.0; + ui.style_mut().visuals.widgets.active.expansion = 0.0; + // Setup fill colors. + ui.visuals_mut().widgets.inactive.weak_bg_fill = Colors::white_or_black(false); + ui.visuals_mut().widgets.hovered.weak_bg_fill = Colors::fill_lite(); + ui.visuals_mut().widgets.active.weak_bg_fill = Colors::fill(); + // Setup stroke colors. + ui.visuals_mut().widgets.inactive.bg_stroke = View::item_stroke(); + ui.visuals_mut().widgets.hovered.bg_stroke = View::item_stroke(); + ui.visuals_mut().widgets.active.bg_stroke = View::hover_stroke(); + + let shift = self.state.shift.load(Ordering::Relaxed); + let label = if shift { + s.to_uppercase() + } else { + s.to_string() + }; + let mut button = Button::new(RichText::new(label.clone()).size(18.0).color(color)) + .corner_radius(egui::CornerRadius::ZERO); + if let Some(bg) = bg { + button = button.fill(bg); + } + // Setup long press/touch. + let long_press = s == BACKSPACE; + if long_press { + button = button.sense(Sense::click_and_drag()); + } + // Draw button. + let resp = button.ui(ui).on_hover_cursor(CursorIcon::PointingHand); + if resp.clicked() || resp.long_touched() || resp.dragged() { + cb(label, self); + } + }) + .response + } + + /// Draw input button. + fn input_button_ui(&mut self, s: &str, translate: bool, ui: &mut egui::Ui) -> Rect { + let value = if translate { + t!( + format!("keyboard.{}", s), + locale = Self::input_locale().as_str() + ) + .into() + } else { + s.to_string() + }; + let rect = self + .custom_button_ui(value, Colors::text_button(), None, ui, |l, c| { + c.state.last_event = Arc::new(Some(KeyboardEvent::TEXT(l))); + c.state.shift.store(false, Ordering::Relaxed); + }) + .rect; + rect + } + + /// Get input locale. + fn input_locale() -> String { + let english = AppConfig::english_keyboard(); + if english { + "en".to_string() + } else { + AppConfig::locale().unwrap_or("en".to_string()) + } + } + + /// Check last keyboard input event. + pub fn consume_event() -> Option { + let empty = { + let r_state = WINDOW_STATE.read(); + r_state.last_event.is_none() + }; + if !empty { + let mut w_state = WINDOW_STATE.write(); + let event = w_state.last_event.as_ref().clone().unwrap(); + w_state.last_event = Arc::new(None); + return Some(event); + } + None + } + + /// Emulate stop of Shift key press. + pub fn unshift() { + let r_state = WINDOW_STATE.read(); + r_state.shift.store(false, Ordering::Relaxed); + } + + /// Reset keyboard window state. + pub fn reset_window_state() { + let mut w_state = WINDOW_STATE.write(); + w_state.layout = Arc::new(KeyboardLayout::TEXT); + // *w_state = KeyboardState::default(); + } +} diff --git a/src/gui/views/input/mod.rs b/src/gui/views/input/mod.rs new file mode 100644 index 00000000..8b258970 --- /dev/null +++ b/src/gui/views/input/mod.rs @@ -0,0 +1,22 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +mod types; +pub use types::*; + +mod edit; +pub use edit::*; + +mod keyboard; +pub use keyboard::*; diff --git a/src/gui/views/input/types.rs b/src/gui/views/input/types.rs new file mode 100644 index 00000000..dc771175 --- /dev/null +++ b/src/gui/views/input/types.rs @@ -0,0 +1,53 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +/// Software keyboard input type. +#[derive(Clone, PartialOrd, PartialEq)] +pub enum KeyboardLayout { + TEXT, + SYMBOLS, + NUMBERS, +} + +/// Software keyboard input event. +#[derive(Clone)] +pub enum KeyboardEvent { + TEXT(String), + CLEAR, + ENTER, +} + +/// Software keyboard Window State. +#[derive(Clone)] +pub struct KeyboardState { + /// Last input event. + pub last_event: Arc>, + /// Current layout. + pub layout: Arc, + /// Flag to enter uppercase symbol first. + pub shift: Arc, +} + +impl Default for KeyboardState { + fn default() -> Self { + Self { + last_event: Arc::new(None), + layout: Arc::new(KeyboardLayout::TEXT), + shift: Arc::new(AtomicBool::new(false)), + } + } +} diff --git a/src/gui/views/mod.rs b/src/gui/views/mod.rs new file mode 100644 index 00000000..099cb174 --- /dev/null +++ b/src/gui/views/mod.rs @@ -0,0 +1,50 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +pub mod types; + +mod views; +pub use views::View; + +mod title_panel; +pub use title_panel::*; + +mod modal; +pub use modal::*; + +mod content; +pub use content::*; + +pub mod goblin; +pub mod network; +pub mod settings; +pub mod wallets; + +mod camera; +pub use camera::*; + +mod qr; +pub use qr::*; + +mod file_pick; +pub use file_pick::*; + +mod pull_to_refresh; +pub use pull_to_refresh::*; + +mod scan; +pub use scan::*; + +mod input; +pub use input::*; diff --git a/src/gui/views/modal.rs b/src/gui/views/modal.rs new file mode 100755 index 00000000..6ca08cb5 --- /dev/null +++ b/src/gui/views/modal.rs @@ -0,0 +1,389 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::epaint::{RectShape, Shadow}; +use egui::os::OperatingSystem; +use egui::{Align2, Color32, CornerRadius, RichText, Stroke, StrokeKind, UiBuilder, Vec2}; +use lazy_static::lazy_static; +use parking_lot::RwLock; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::{ModalPosition, ModalState}; +use crate::gui::views::{Content, View}; + +lazy_static! { + /// Showing [`Modal`] state to be accessible from different ui parts. + static ref MODAL_STATE: Arc> = Arc::new(RwLock::new(ModalState::default())); +} + +/// Modal [`egui::Window`] container. +#[derive(Clone)] +pub struct Modal { + /// Identifier for modal. + pub(crate) id: &'static str, + /// Position on the screen. + pub position: ModalPosition, + /// Flag to check if modal can be closed by keys. + closeable: Arc, + /// Title text. + title: Option, + /// Flag to check first content render. + first_draw: Arc, + /// Background color. + fill: Option, +} + +impl Modal { + /// Margin from [`Modal`] window at top/left/right. + const DEFAULT_MARGIN: f32 = 8.0; + /// Maximum width of the content. + const DEFAULT_WIDTH: f32 = Content::SIDE_PANEL_WIDTH - (2.0 * Self::DEFAULT_MARGIN); + /// Modal content [`egui::Window`] id. + pub const WINDOW_ID: &'static str = "modal_window"; + + /// Create closeable [`Modal`] with center position. + pub fn new(id: &'static str) -> Self { + Self { + id, + position: ModalPosition::Center, + closeable: Arc::new(AtomicBool::new(true)), + title: None, + first_draw: Arc::new(AtomicBool::new(true)), + fill: None, + } + } + + /// Setup position of [`Modal`] on the screen. + pub fn position(mut self, position: ModalPosition) -> Self { + self.position = position; + self + } + + /// Change [`Modal`] position on the screen. + pub fn change_position(position: ModalPosition) { + let mut w_state = MODAL_STATE.write(); + w_state.modal.as_mut().unwrap().position = position; + } + + /// Close [`Modal`] by clearing its state. + pub fn close() { + let mut w_nav = MODAL_STATE.write(); + w_nav.modal = None; + } + + /// Setup possibility to close [`Modal`]. + pub fn closeable(self, closeable: bool) -> Self { + self.closeable.store(closeable, Ordering::Relaxed); + self + } + + /// Disable possibility to close [`Modal`]. + pub fn disable_closing(&self) { + self.closeable.store(false, Ordering::Relaxed); + } + + /// Enable possibility to close [`Modal`]. + pub fn enable_closing(&self) { + self.closeable.store(true, Ordering::Relaxed); + } + + /// Check if [`Modal`] is closeable. + pub fn is_closeable(&self) -> bool { + self.closeable.load(Ordering::Relaxed) + } + + /// Set title text on [`Modal`] creation. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into().to_uppercase()); + self + } + + /// Set [`Modal`] instance into state to show at ui. + pub fn show(self) { + let mut w_nav = MODAL_STATE.write(); + self.first_draw.store(true, Ordering::Relaxed); + w_nav.modal = Some(self); + } + + /// Remove [`Modal`] from [`ModalState`] if it's showing and can be closed. + /// Return `false` if modal existed in state before call. + pub fn on_back() -> bool { + if Self::opened().is_some() { + if Self::opened_closeable() { + Self::close(); + } + return false; + } + true + } + + /// Return identifier of opened [`Modal`]. + pub fn opened() -> Option<&'static str> { + // Check if modal is showing. + { + if MODAL_STATE.read().modal.is_none() { + return None; + } + } + + // Get identifier of opened modal. + let r_state = MODAL_STATE.read(); + let modal = r_state.modal.as_ref().unwrap(); + Some(modal.id) + } + + /// Check if [`Modal`] is opened and can be closed. + pub fn opened_closeable() -> bool { + // Check if modal is showing. + { + if MODAL_STATE.read().modal.is_none() { + return false; + } + } + let r_state = MODAL_STATE.read(); + let modal = r_state.modal.as_ref().unwrap(); + modal.closeable.load(Ordering::Relaxed) + } + + /// Set title text for current opened [`Modal`]. + pub fn set_title(title: impl Into) { + let mut w_state = MODAL_STATE.write(); + if w_state.modal.is_some() { + let mut modal = w_state.modal.clone().unwrap(); + modal.title = Some(title.into().to_uppercase()); + w_state.modal = Some(modal); + } + } + + /// Check for first [`Modal`] content rendering. + pub fn first_draw() -> bool { + if Self::opened().is_none() { + return false; + } + let r_state = MODAL_STATE.read(); + let modal = r_state.modal.as_ref().unwrap(); + modal.first_draw.load(Ordering::Relaxed) + } + + pub fn ui( + ctx: &egui::Context, + cb: &dyn PlatformCallbacks, + add_content: impl FnOnce(&mut egui::Ui, &Modal, &dyn PlatformCallbacks), + ) { + let has_modal = { MODAL_STATE.read().modal.is_some() }; + if has_modal { + let modal = { + let r_state = MODAL_STATE.read(); + r_state.modal.clone().unwrap() + }; + modal.window_ui(ctx, cb, add_content); + } + } + + /// Draw [`egui::Window`] with provided content. + fn window_ui( + &self, + ctx: &egui::Context, + cb: &dyn PlatformCallbacks, + add_content: impl FnOnce(&mut egui::Ui, &Modal, &dyn PlatformCallbacks), + ) { + let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); + + // Setup background rect. + let is_win = OperatingSystem::Windows == OperatingSystem::from_target_os(); + let bg_rect = if View::is_desktop() && !is_win { + let mut r = ctx.content_rect(); + let is_mac = OperatingSystem::Mac == OperatingSystem::from_target_os(); + if !is_mac && !is_fullscreen { + r = r.shrink(Content::WINDOW_FRAME_MARGIN - 1.0); + } + r.min.y += Content::WINDOW_TITLE_HEIGHT; + r + } else { + ctx.content_rect() + }; + + // Draw modal background. + egui::Window::new("modal_bg_window") + .title_bar(false) + .resizable(false) + .collapsible(false) + .fixed_rect(bg_rect) + .frame(egui::Frame { + fill: Colors::semi_transparent(), + ..Default::default() + }) + .show(ctx, |ui| { + ui.set_min_size(bg_rect.size()); + }); + + // Setup width of modal content. + let side_insets = View::get_left_inset() + View::get_right_inset(); + let available_width = ctx.content_rect().width() - (side_insets + Self::DEFAULT_MARGIN); + let width = f32::min(available_width, Self::DEFAULT_WIDTH); + + // Show main content window at given position. + let (content_align, content_offset) = self.modal_position(); + egui::Window::new(Self::WINDOW_ID) + .title_bar(false) + .resizable(false) + .collapsible(false) + .min_width(width) + .default_width(width) + .anchor(content_align, content_offset) + .frame(egui::Frame { + shadow: Shadow { + offset: Default::default(), + blur: 30.0 as u8, + spread: 3.0 as u8, + color: egui::Color32::from_black_alpha(32), + }, + corner_radius: CornerRadius::same(8.0 as u8), + ..Default::default() + }) + .show(ctx, |ui| { + if let Some(title) = &self.title { + title_ui(title, ui); + } + self.content_ui(ui, cb, add_content); + }); + + // Setup first draw flag. + if Self::first_draw() { + let r_state = MODAL_STATE.read(); + let modal = r_state.modal.as_ref().unwrap(); + modal.first_draw.store(false, Ordering::Relaxed); + } + } + + /// Get [`egui::Window`] position based on [`ModalPosition`]. + fn modal_position(&self) -> (Align2, Vec2) { + let align = match self.position { + ModalPosition::CenterTop => Align2::CENTER_TOP, + ModalPosition::Center => Align2::CENTER_CENTER, + }; + + let x_align = View::get_left_inset() - View::get_right_inset(); + let is_mac = OperatingSystem::Mac == OperatingSystem::from_target_os(); + let is_win = OperatingSystem::Windows == OperatingSystem::from_target_os(); + let extra_y = if View::is_desktop() && !is_win { + Content::WINDOW_TITLE_HEIGHT + + if !is_mac { + Content::WINDOW_FRAME_MARGIN + } else { + 0.0 + } + } else { + 0.0 + }; + let y_align = View::get_top_inset() + Self::DEFAULT_MARGIN / 2.0 + extra_y; + + let offset = match self.position { + ModalPosition::CenterTop => Vec2::new(x_align, y_align), + ModalPosition::Center => Vec2::new(x_align, 0.0), + }; + (align, offset) + } + + /// Set custom background color. + pub fn set_background_color(&self, color: Color32) { + let mut w_state = MODAL_STATE.write(); + w_state.modal.as_mut().unwrap().fill = Some(color); + } + + /// Draw provided content. + fn content_ui( + &self, + ui: &mut egui::Ui, + cb: &dyn PlatformCallbacks, + add_content: impl FnOnce(&mut egui::Ui, &Modal, &dyn PlatformCallbacks), + ) { + let mut rect = ui.available_rect_before_wrap(); + + // Create background shape. + let mut bg_shape = RectShape::new( + rect, + if self.title.is_none() { + CornerRadius::same(8.0 as u8) + } else { + CornerRadius { + nw: 0.0 as u8, + ne: 0.0 as u8, + sw: 8.0 as u8, + se: 8.0 as u8, + } + }, + self.fill.unwrap_or(Colors::fill_lite()), + Stroke::NONE, + StrokeKind::Outside, + ); + let bg_idx = ui.painter().add(bg_shape.clone()); + + rect.min += egui::emath::vec2(6.0, 0.0); + rect.max -= egui::emath::vec2(6.0, 0.0); + let resp = ui + .scope_builder(UiBuilder::new().max_rect(rect), |ui| { + (add_content)(ui, self, cb); + }) + .response; + + // Setup background size. + let bg_rect = { + let mut r = resp.rect.clone(); + r.min -= egui::emath::vec2(6.0, 0.0); + r.max += egui::emath::vec2(6.0, 0.0); + r + }; + bg_shape.rect = bg_rect; + ui.painter().set(bg_idx, bg_shape); + } +} + +/// Draw title content. +fn title_ui(title: &String, ui: &mut egui::Ui) { + let rect = ui.available_rect_before_wrap(); + + // Create background shape. + let mut bg_shape = RectShape::new( + rect, + CornerRadius { + nw: 8.0 as u8, + ne: 8.0 as u8, + sw: 0.0 as u8, + se: 0.0 as u8, + }, + Colors::yellow(), + Stroke::NONE, + StrokeKind::Outside, + ); + let bg_idx = ui.painter().add(bg_shape.clone()); + + // Draw title content. + let resp = ui + .vertical_centered(|ui| { + ui.add_space(Modal::DEFAULT_MARGIN + 2.0); + ui.label(RichText::new(title).size(19.0).color(Colors::title(true))); + ui.add_space(Modal::DEFAULT_MARGIN + 1.0); + // Draw line below title. + View::horizontal_line(ui, Colors::item_stroke()); + }) + .response; + + // Setup background size. + bg_shape.rect = resp.rect; + ui.painter().set(bg_idx, bg_shape); +} diff --git a/src/gui/views/network/connections.rs b/src/gui/views/network/connections.rs new file mode 100644 index 00000000..b21f4f0a --- /dev/null +++ b/src/gui/views/network/connections.rs @@ -0,0 +1,393 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use eframe::epaint::RectShape; +use egui::{ + Align, Color32, CornerRadius, CursorIcon, Layout, RichText, Sense, StrokeKind, UiBuilder, +}; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{ + CHECK_CIRCLE, COMPUTER_TOWER, DOTS_THREE_CIRCLE, GLOBE_SIMPLE, PLUS_CIRCLE, POWER, QR_CODE, + TRASH, WARNING_CIRCLE, X_CIRCLE, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::NodeSetup; +use crate::gui::views::network::modals::{ExternalConnectionModal, ShareConnectionContent}; +use crate::gui::views::network::types::ShareConnection; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{Modal, View}; +use crate::node::{Node, NodeConfig}; +use crate::wallet::{ConnectionsConfig, ExternalConnection}; + +/// Network connections content. +pub struct ConnectionsContent { + /// Flag to check connections state on first draw. + first_draw: bool, + + /// External connection [`Modal`] content. + ext_conn_modal_content: ExternalConnectionModal, + + /// [`Modal`] content to share connection with QR code. + share_conn_modal_content: Option, +} + +impl Default for ConnectionsContent { + fn default() -> Self { + Self { + first_draw: true, + ext_conn_modal_content: ExternalConnectionModal::new(None), + share_conn_modal_content: None, + } + } +} + +/// Identifier for [`Modal`] to share connection. +const SHARE_CONN_QR_MODAL: &'static str = "share_conn_qr_modal"; + +impl ContentContainer for ConnectionsContent { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ExternalConnectionModal::NETWORK_ID, SHARE_CONN_QR_MODAL] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + ExternalConnectionModal::NETWORK_ID => { + self.ext_conn_modal_content.ui(ui, cb, modal, |_| {}); + } + SHARE_CONN_QR_MODAL => { + if let Some(c) = self.share_conn_modal_content.as_mut() { + c.ui(ui, modal, cb); + } + } + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + // Check connections state on first draw. + if self.first_draw { + ExternalConnection::check(None, ui.ctx()); + self.first_draw = false; + } + + ui.add_space(2.0); + + // Show network type selection. + let saved_chain_type = AppConfig::chain_type(); + NodeSetup::chain_type_ui(ui); + ui.add_space(6.0); + + // Check connections availability. + if saved_chain_type != AppConfig::chain_type() { + ExternalConnection::check(None, ui.ctx()); + } + + // Show integrated node info content. + Self::integrated_node_item_ui( + ui, + Colors::fill_lite(), + (true, || { + AppConfig::toggle_show_connections_network_panel(); + }), + |ui| { + let r = View::item_rounding(0, 1, true); + View::item_button(ui, r, QR_CODE, None, || { + if let Ok(c) = ShareConnectionContent::new(ShareConnection { + url: format!("http://{}", NodeConfig::get_api_address()), + username: "grin".to_string(), + secret: NodeConfig::get_api_secret(true).unwrap_or("".to_string()), + }) { + self.share_conn_modal_content = Some(c); + // Show QR code to share integrated node connection. + Modal::new(SHARE_CONN_QR_MODAL) + .position(ModalPosition::Center) + .title(t!("network.node")) + .show(); + } + }); + true + }, + ); + + // Show external connections. + ui.add_space(8.0); + ui.label( + RichText::new(t!("wallets.ext_conn")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + // Show button to add new external node connection. + let add_node_text = format!("{} {}", PLUS_CIRCLE, t!("wallets.add_node")); + View::button(ui, add_node_text, Colors::white_or_black(false), || { + self.show_add_ext_conn_modal(None); + }); + + ui.add_space(4.0); + + let ext_conn_list = ConnectionsConfig::ext_conn_list(); + let len = ext_conn_list.len(); + if len != 0 { + ui.add_space(8.0); + for (i, c) in ext_conn_list.iter().enumerate() { + ui.horizontal_wrapped(|ui| { + let mut show_qr_content: Option = None; + // Draw external connection list item. + let bg = Colors::fill_lite(); + Self::ext_conn_item_ui( + ui, + bg, + c, + i, + len, + (true, || { + self.show_add_ext_conn_modal(Some(c.clone())); + }), + |ui| { + // Draw button to delete connection. + let r = View::item_rounding(i, len, true); + View::item_button(ui, r, TRASH, Some(Colors::inactive_text()), || { + ConnectionsConfig::remove_ext_conn(c.id); + }); + // Draw button to share connection + let r = CornerRadius::default(); + View::item_button(ui, r, QR_CODE, None, || { + if let Ok(c) = ShareConnectionContent::new(ShareConnection { + url: c.url.clone(), + username: "grin".to_string(), + secret: c.secret.clone().unwrap_or("".to_string()), + }) { + show_qr_content = Some(c); + } + }); + }, + ); + if let Some(c) = show_qr_content { + self.share_conn_modal_content = Some(c); + // Show QR code to share external connection. + Modal::new(SHARE_CONN_QR_MODAL) + .position(ModalPosition::Center) + .title(t!("wallets.ext_conn").replace(":", "")) + .show(); + } + }); + } + } + } +} + +impl ConnectionsContent { + /// Draw integrated node connection item content. + pub fn integrated_node_item_ui( + ui: &mut egui::Ui, + bg: Color32, + on_click: (bool, impl FnOnce()), + custom_button: impl FnOnce(&mut egui::Ui) -> bool, + ) { + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(78.0); + let r = View::item_rounding(0, 1, false); + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + // Draw custom button. + let extra_button = custom_button(ui); + + // Draw buttons to start/stop node. + if Node::get_error().is_none() { + let rounding = if extra_button { + CornerRadius::default() + } else { + View::item_rounding(0, 1, true) + }; + if !Node::is_running() { + View::item_button(ui, rounding, POWER, Some(Colors::green()), || { + Node::start(); + }); + } else if !Node::is_starting() + && !Node::is_stopping() + && !Node::is_restarting() + { + View::item_button(ui, rounding, POWER, Some(Colors::red()), || { + Node::stop(false); + }); + } + } + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.add_space(1.0); + ui.label( + RichText::new(t!("network.node")) + .size(18.0) + .color(Colors::title(false)), + ); + }); + + // Setup node status text. + let has_error = Node::get_error().is_some(); + let status_icon = if has_error { + WARNING_CIRCLE + } else if !Node::is_running() { + X_CIRCLE + } else if Node::not_syncing() { + CHECK_CIRCLE + } else { + DOTS_THREE_CIRCLE + }; + let status_text = format!( + "{} {}", + status_icon, + if has_error { + t!("error").into() + } else { + Node::get_sync_status_text() + } + ); + View::ellipsize_text(ui, status_text, 15.0, Colors::text(false)); + ui.add_space(1.0); + + // Setup node API address text. + let api_address = NodeConfig::get_api_address(); + let address_text = + format!("{} http://{}", COMPUTER_TOWER, api_address); + ui.label( + RichText::new(address_text).size(15.0).color(Colors::gray()), + ); + }) + }, + ); + }, + ) + .response; + let (clickable, on_click) = on_click; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if clickable && res.hovered() { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::fill(); + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked && clickable { + on_click(); + } + } + + /// Draw external connection item content. + pub fn ext_conn_item_ui( + ui: &mut egui::Ui, + bg: Color32, + conn: &ExternalConnection, + index: usize, + len: usize, + on_click: (bool, impl FnOnce()), + custom_button: impl FnOnce(&mut egui::Ui), + ) { + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(52.0); + let r = View::item_rounding(index, len, false); + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + // Draw custom button. + custom_button(ui); + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + // Draw connections URL. + ui.add_space(4.0); + let conn_text = format!("{} {}", GLOBE_SIMPLE, conn.url); + View::ellipsize_text(ui, conn_text, 15.0, Colors::title(false)); + ui.add_space(1.0); + + // Setup connection status text. + let status_text = if let Some(available) = conn.available { + if available { + format!("{} {}", CHECK_CIRCLE, t!("network.available")) + } else { + format!("{} {}", X_CIRCLE, t!("network.not_available")) + } + } else { + format!( + "{} {}", + DOTS_THREE_CIRCLE, + t!("network.availability_check") + ) + }; + ui.label( + RichText::new(status_text).size(15.0).color(Colors::gray()), + ); + ui.add_space(3.0); + }); + }, + ); + }, + ) + .response; + let (clickable, on_click) = on_click; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if clickable && res.hovered() { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::fill(); + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked && clickable { + on_click(); + } + } + + /// Show [`Modal`] to add external connection. + pub fn show_add_ext_conn_modal(&mut self, conn: Option) { + self.ext_conn_modal_content = ExternalConnectionModal::new(conn); + // Show modal. + Modal::new(ExternalConnectionModal::NETWORK_ID) + .position(ModalPosition::CenterTop) + .title(t!("wallets.add_node")) + .show(); + } +} diff --git a/src/gui/views/network/content.rs b/src/gui/views/network/content.rs new file mode 100644 index 00000000..7d2cc03a --- /dev/null +++ b/src/gui/views/network/content.rs @@ -0,0 +1,446 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Id, Margin, RichText, ScrollArea}; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{ + ARROW_LEFT, ARROWS_COUNTER_CLOCKWISE, BRIEFCASE, DATABASE, DOTS_THREE_OUTLINE_VERTICAL, + FACTORY, FADERS, GAUGE, GEAR, GLOBE, POWER, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::types::{NodeTab, NodeTabType}; +use crate::gui::views::network::{ + ConnectionsContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings, +}; +use crate::gui::views::settings::SettingsContent; +use crate::gui::views::types::{ContentContainer, LinePosition, TitleContentType, TitleType}; +use crate::gui::views::{Content, TitlePanel, View}; +use crate::node::{Node, NodeConfig, NodeError}; + +/// Network content. +pub struct NetworkContent { + /// Current integrated node tab content. + node_tab_content: Box, + /// Connections content. + connections: ConnectionsContent, + + /// Application settings content. + settings_content: Option, +} + +impl Default for NetworkContent { + fn default() -> Self { + Self { + node_tab_content: Box::new(NetworkNode::default()), + connections: ConnectionsContent::default(), + settings_content: None, + } + } +} + +impl NetworkContent { + pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let show_settings = self.showing_settings(); + let show_connections = AppConfig::show_connections_network_panel(); + let dual_panel = Content::is_dual_panel_mode(ui.ctx()); + + // Show title panel. + self.title_ui(ui, dual_panel, show_connections); + + // Show integrated node tabs content. + if !show_connections && !show_settings { + let side_padding = View::TAB_ITEMS_PADDING + if View::is_desktop() { 0.0 } else { 4.0 }; + let tabs_margin = Margin { + left: (View::get_left_inset() + side_padding) as i8, + right: (View::far_right_inset_margin(ui) + side_padding) as i8, + top: View::TAB_ITEMS_PADDING as i8, + bottom: (View::get_bottom_inset() + View::TAB_ITEMS_PADDING) as i8, + }; + egui::TopBottomPanel::bottom("network_tabs_content") + .min_height(0.5) + .resizable(false) + .frame(egui::Frame { + inner_margin: tabs_margin, + fill: Colors::fill(), + ..Default::default() + }) + .show_inside(ui, |ui| { + let rect = ui.available_rect_before_wrap(); + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + self.tabs_ui(ui); + }); + // Draw content divider line. + let r = { + let mut r = rect.clone(); + r.min.x -= tabs_margin.left as f32; + r.min.y -= tabs_margin.top as f32; + r.max.x += tabs_margin.right as f32; + r + }; + View::line(ui, LinePosition::TOP, &r, Colors::stroke()); + }); + } + + // Show settings or integrated node content. + egui::SidePanel::right("network_side_content") + .resizable(false) + .exact_width(ui.available_width()) + .frame(egui::Frame { + ..Default::default() + }) + .show_animated_inside(ui, show_settings || !show_connections, |ui| { + egui::CentralPanel::default() + .frame(egui::Frame { + inner_margin: Margin { + left: (View::get_left_inset() + View::content_padding()) as i8, + right: (View::far_right_inset_margin(ui) + View::content_padding()) + as i8, + top: 3.0 as i8, + bottom: 4.0 as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + let rect = ui.available_rect_before_wrap(); + if let Some(c) = &mut self.settings_content { + ScrollArea::vertical() + .id_salt("app_settings_network") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(1.0); + ui.vertical_centered(|ui| { + // Show application settings content. + View::max_width_ui( + ui, + Content::SIDE_PANEL_WIDTH * 1.3, + |ui| { + c.ui(ui, cb); + }, + ); + }); + }); + } else if self.node_tab_content.get_type() != NodeTabType::Settings { + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + let node_err = Node::get_error(); + if let Some(err) = node_err { + node_error_ui(ui, err); + } else if !Node::is_running() { + disabled_node_ui(ui); + } else if Node::get_stats().is_none() + || Node::is_restarting() || Node::is_stopping() + { + NetworkContent::loading_ui(ui, None::); + } else { + self.node_tab_content.tab_ui(ui, cb); + } + }); + } else { + self.node_tab_content.tab_ui(ui, cb); + } + // Draw content divider line. + let r = { + let mut r = rect.clone(); + r.min.y -= 3.0; + r.max.x += View::content_padding(); + r.max.y += View::content_padding(); + r + }; + if dual_panel { + View::line(ui, LinePosition::RIGHT, &r, Colors::item_stroke()); + } + }); + }); + + // Show connections content. + egui::CentralPanel::default() + .frame(egui::Frame { + inner_margin: Margin { + left: if show_connections { + View::get_left_inset() + View::content_padding() + } else { + 0.0 + } as i8, + right: if show_connections { + View::far_right_inset_margin(ui) + View::content_padding() + } else { + 0.0 + } as i8, + top: 3.0 as i8, + bottom: 0.0 as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + let rect = ui.available_rect_before_wrap(); + ScrollArea::vertical() + .id_salt("connections_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(1.0); + ui.vertical_centered(|ui| { + let max_width = if !dual_panel { + Content::SIDE_PANEL_WIDTH * 1.3 + } else { + ui.available_width() + }; + View::max_width_ui(ui, max_width, |ui| { + self.connections.ui(ui, cb); + }); + }); + ui.add_space(32.0); + }); + // Draw content divider line. + let r = { + let mut r = rect.clone(); + r.min.y -= 3.0; + r.max.x += View::content_padding(); + r.max.y += View::content_padding() + View::get_bottom_inset(); + r + }; + if show_connections && dual_panel { + View::line(ui, LinePosition::RIGHT, &r, Colors::item_stroke()); + } + }); + + // Redraw after delay if node is running at non-dual-panel mode. + if ((!dual_panel && Content::is_network_panel_open()) || dual_panel) && Node::is_running() { + ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY); + } + } + + /// Navigate back, return `true` if action was not consumed. + pub fn on_back(&mut self) -> bool { + if self.showing_settings() { + // Close settings. + self.settings_content = None; + return false; + } + true + } + + /// Check if application settings content is showing. + pub fn showing_settings(&self) -> bool { + self.settings_content.is_some() + } + + /// Draw tab buttons at bottom of the screen. + fn tabs_ui(&mut self, ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + // Setup spacing between tabs. + ui.style_mut().spacing.item_spacing = egui::vec2(View::TAB_ITEMS_PADDING, 0.0); + + // Draw tab buttons. + let current_type = self.node_tab_content.get_type(); + ui.columns(4, |columns| { + columns[0].vertical_centered_justified(|ui| { + let active = Some(current_type == NodeTabType::Info); + View::tab_button(ui, DATABASE, None, active, |_| { + self.node_tab_content = Box::new(NetworkNode::default()); + }); + }); + columns[1].vertical_centered_justified(|ui| { + let active = Some(current_type == NodeTabType::Metrics); + View::tab_button(ui, GAUGE, None, active, |_| { + self.node_tab_content = Box::new(NetworkMetrics::default()); + }); + }); + columns[2].vertical_centered_justified(|ui| { + let active = Some(current_type == NodeTabType::Mining); + View::tab_button(ui, FACTORY, None, active, |_| { + self.node_tab_content = Box::new(NetworkMining::default()); + }); + }); + columns[3].vertical_centered_justified(|ui| { + let active = Some(current_type == NodeTabType::Settings); + View::tab_button(ui, FADERS, None, active, |_| { + self.node_tab_content = Box::new(NetworkSettings::default()); + }); + }); + }); + }); + } + + /// Draw title content. + fn title_ui(&mut self, ui: &mut egui::Ui, dual_panel: bool, show_connections: bool) { + let show_settings = self.showing_settings(); + + // Setup values for title panel. + let title_text = self.node_tab_content.get_type().title(); + let subtitle_text = Node::get_sync_status_text().into(); + let not_syncing = Node::not_syncing() && !Node::data_dir_changing(); + let title_content = if show_settings { + TitleContentType::Title(t!("settings").into()) + } else if !show_connections { + TitleContentType::WithSubTitle(title_text, subtitle_text, !not_syncing) + } else { + TitleContentType::Title(t!("network.connections").into()) + }; + + // Draw title panel. + TitlePanel::new(Id::from("network_title_panel")).ui( + TitleType::Single(title_content), + |ui| { + if show_settings { + View::title_button_big(ui, ARROW_LEFT, |_| { + self.settings_content = None; + }); + } else if !show_connections { + View::title_button_big(ui, GLOBE, |_| { + AppConfig::toggle_show_connections_network_panel(); + }); + } else if !dual_panel { + View::title_button_big(ui, GEAR, |_| { + self.settings_content = Some(SettingsContent::default()); + }); + } + }, + |ui| { + if !dual_panel && !show_settings { + View::title_button_big(ui, BRIEFCASE, |_| { + Content::toggle_network_panel(); + }); + } + }, + ui, + ); + } + + /// Content to draw on loading. + pub fn loading_ui(ui: &mut egui::Ui, text: Option>) { + match text { + None => { + ui.centered_and_justified(|ui| { + View::big_loading_spinner(ui); + }); + } + Some(t) => { + View::center_content(ui, 162.0, |ui| { + View::big_loading_spinner(ui); + ui.add_space(18.0); + ui.label(RichText::new(t).size(16.0).color(Colors::inactive_text())); + }); + } + } + } + + /// Draw checkbox to run integrated node on application launch. + pub fn autorun_node_ui(ui: &mut egui::Ui) { + let autostart = AppConfig::autostart_node(); + View::checkbox(ui, autostart, t!("network.autorun"), || { + AppConfig::toggle_node_autostart(); + }); + } +} + +/// Content to draw when node is disabled. +fn disabled_node_ui(ui: &mut egui::Ui) { + View::center_content(ui, 156.0, |ui| { + let text = t!("network.disabled_server", "dots" => DOTS_THREE_OUTLINE_VERTICAL); + ui.label( + RichText::new(text) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(8.0); + View::action_button( + ui, + format!("{} {}", POWER, t!("network.enable_node")), + || { + Node::start(); + }, + ); + ui.add_space(2.0); + NetworkContent::autorun_node_ui(ui); + }); +} + +/// Draw integrated node error content. +pub fn node_error_ui(ui: &mut egui::Ui, e: NodeError) { + match e { + NodeError::Storage => { + View::center_content(ui, 156.0, |ui| { + ui.label( + RichText::new(t!("network_node.error_clean")) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(8.0); + let btn_txt = format!("{} {}", ARROWS_COUNTER_CLOCKWISE, t!("network_node.resync")); + View::action_button(ui, btn_txt, || { + Node::clean_up_data(); + Node::start(); + }); + ui.add_space(2.0); + }); + return; + } + NodeError::P2P | NodeError::API => { + let msg_type = match e { + NodeError::API => "API", + _ => "P2P", + }; + View::center_content(ui, 106.0, |ui| { + let text = t!( + "network_node.error_p2p_api", + "p2p_api" => msg_type, + "settings" => FADERS + ); + ui.label(RichText::new(text).size(16.0).color(Colors::red())); + ui.add_space(2.0); + }); + return; + } + NodeError::Configuration => { + View::center_content(ui, 106.0, |ui| { + ui.label( + RichText::new(t!("network_node.error_config", "settings" => FADERS)) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(8.0); + let btn_txt = format!( + "{} {}", + ARROWS_COUNTER_CLOCKWISE, + t!("network_settings.reset") + ); + View::action_button(ui, btn_txt, || { + NodeConfig::reset_to_default(); + Node::start(); + }); + ui.add_space(2.0); + }); + } + NodeError::Unknown => { + View::center_content(ui, 156.0, |ui| { + ui.label( + RichText::new(t!("network_node.error_unknown", "settings" => FADERS)) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(8.0); + let btn_txt = format!("{} {}", ARROWS_COUNTER_CLOCKWISE, t!("network_node.resync")); + View::action_button(ui, btn_txt, || { + Node::clean_up_data(); + Node::start(); + }); + ui.add_space(2.0); + }); + } + } +} diff --git a/src/gui/views/network/metrics.rs b/src/gui/views/network/metrics.rs new file mode 100644 index 00000000..5e4ff0d4 --- /dev/null +++ b/src/gui/views/network/metrics.rs @@ -0,0 +1,208 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{CornerRadius, RichText, ScrollArea, StrokeKind, vec2}; +use grin_core::consensus::{DAY_HEIGHT, GRIN_BASE, HOUR_SEC, REWARD}; +use grin_servers::{DiffBlock, ServerStats}; + +use crate::gui::Colors; +use crate::gui::icons::{AT, COINS, CUBE_TRANSPARENT, HOURGLASS_LOW, HOURGLASS_MEDIUM, TIMER}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::NetworkContent; +use crate::gui::views::network::types::{NodeTab, NodeTabType}; +use crate::gui::views::{Content, View}; +use crate::node::Node; + +/// Chain metrics tab content. +#[derive(Default)] +pub struct NetworkMetrics; + +const BLOCK_REWARD: u64 = REWARD / GRIN_BASE; +// 1 year as 365 days and 6 hours (31557600). +const YEARLY_SUPPLY: u64 = (BLOCK_REWARD * DAY_HEIGHT * 365) + 6 * HOUR_SEC; + +impl NodeTab for NetworkMetrics { + fn get_type(&self) -> NodeTabType { + NodeTabType::Metrics + } + + fn tab_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + let server_stats = Node::get_stats(); + let stats = server_stats.as_ref().unwrap(); + if stats.diff_stats.height == 0 { + NetworkContent::loading_ui(ui, Some(t!("network_metrics.loading"))); + return; + } + ui.add_space(1.0); + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + // Show emission and difficulty info. + info_ui(ui, stats); + // Show difficulty adjustment window blocks. + blocks_ui(ui, stats); + }); + } +} + +/// Draw emission and difficulty info. +fn info_ui(ui: &mut egui::Ui, stats: &ServerStats) { + // Show emission info. + View::sub_title(ui, format!("{} {}", COINS, t!("network_metrics.emission"))); + ui.columns(3, |columns| { + let supply = stats.header_stats.height * BLOCK_REWARD; + let rate = (YEARLY_SUPPLY * 100) / supply; + + columns[0].vertical_centered(|ui| { + View::label_box( + ui, + format!("{}ツ", BLOCK_REWARD), + t!("network_metrics.reward"), + [true, false, true, false], + ); + }); + columns[1].vertical_centered(|ui| { + View::label_box( + ui, + format!("{:.2}%", rate), + t!("network_metrics.inflation"), + [false, false, false, false], + ); + }); + columns[2].vertical_centered(|ui| { + View::label_box( + ui, + supply.to_string(), + t!("network_metrics.supply"), + [false, true, false, true], + ); + }); + }); + ui.add_space(5.0); + + // Show difficulty adjustment window info. + let difficulty_title = t!( + "network_metrics.difficulty_window", + "size" => stats.diff_stats.window_size + ); + View::sub_title(ui, format!("{} {}", HOURGLASS_MEDIUM, difficulty_title)); + ui.columns(3, |columns| { + columns[0].vertical_centered(|ui| { + View::label_box( + ui, + stats.diff_stats.height.to_string(), + t!("network_node.height"), + [true, false, true, false], + ); + }); + columns[1].vertical_centered(|ui| { + View::label_box( + ui, + format!("{}s", stats.diff_stats.average_block_time), + t!("network_metrics.block_time"), + [false, false, false, false], + ); + }); + columns[2].vertical_centered(|ui| { + View::label_box( + ui, + stats.diff_stats.average_difficulty.to_string(), + t!("network_node.difficulty"), + [false, true, false, true], + ); + }); + }); +} + +const BLOCK_ITEM_HEIGHT: f32 = 77.0; + +/// Draw difficulty adjustment window blocks content. +fn blocks_ui(ui: &mut egui::Ui, stats: &ServerStats) { + let blocks_size = stats.diff_stats.last_blocks.len(); + ui.add_space(4.0); + ScrollArea::vertical() + .id_salt("mining_difficulty_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .stick_to_bottom(true) + .show_rows(ui, BLOCK_ITEM_HEIGHT, blocks_size, |ui, row_range| { + ui.add_space(4.0); + for index in row_range { + let db = stats.diff_stats.last_blocks.get(index).unwrap(); + block_item_ui(ui, db, View::item_rounding(index, blocks_size, false)); + } + }); +} + +/// Draw block difficulty item. +fn block_item_ui(ui: &mut egui::Ui, db: &DiffBlock, rounding: CornerRadius) { + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(BLOCK_ITEM_HEIGHT); + ui.allocate_ui(rect.size(), |ui| { + ui.horizontal(|ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(4.0); + + // Draw round background. + rect.min += vec2(8.0, 0.0); + rect.max -= vec2(8.0, 0.0); + ui.painter().rect( + rect, + rounding, + Colors::fill(), + View::item_stroke(), + StrokeKind::Outside, + ); + + // Draw block hash. + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label( + RichText::new(db.block_hash.to_string()) + .color(Colors::white_or_black(true)) + .size(17.0), + ); + }); + // Draw block difficulty and height. + ui.horizontal(|ui| { + ui.add_space(7.0); + let diff_text = format!( + "{} {} {} {}", + CUBE_TRANSPARENT, db.difficulty, AT, db.block_height + ); + ui.label( + RichText::new(diff_text) + .color(Colors::title(false)) + .size(15.0), + ); + }); + // Draw block date. + ui.horizontal(|ui| { + ui.add_space(7.0); + let block_time = View::format_time(db.time as i64); + ui.label( + RichText::new(format!( + "{} {}s {} {}", + TIMER, db.duration, HOURGLASS_LOW, block_time + )) + .color(Colors::gray()) + .size(15.0), + ); + }); + ui.add_space(3.0); + }); + ui.add_space(6.0); + }); + }); +} diff --git a/src/gui/views/network/mining.rs b/src/gui/views/network/mining.rs new file mode 100644 index 00000000..9ce097d6 --- /dev/null +++ b/src/gui/views/network/mining.rs @@ -0,0 +1,303 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{CornerRadius, RichText, ScrollArea, StrokeKind}; +use grin_chain::SyncStatus; +use grin_servers::WorkerStats; + +use crate::gui::Colors; +use crate::gui::icons::{ + BARBELL, CLOCK_AFTERNOON, CPU, CUBE, FADERS, FOLDER_DASHED, FOLDER_SIMPLE_MINUS, + FOLDER_SIMPLE_PLUS, HARD_DRIVES, PLUGS, PLUGS_CONNECTED, POLYGON, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::NetworkContent; +use crate::gui::views::network::setup::StratumSetup; +use crate::gui::views::network::types::{NodeTab, NodeTabType}; +use crate::gui::views::types::ContentContainer; +use crate::gui::views::{Content, View}; +use crate::node::{Node, NodeConfig}; + +/// Mining tab content. +pub struct NetworkMining { + /// Stratum server setup content. + stratum_server_setup: StratumSetup, +} + +impl Default for NetworkMining { + fn default() -> Self { + Self { + stratum_server_setup: StratumSetup::default(), + } + } +} + +impl NodeTab for NetworkMining { + fn get_type(&self) -> NodeTabType { + NodeTabType::Mining + } + + fn tab_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + if Node::is_stratum_starting() || Node::get_sync_status().unwrap() != SyncStatus::NoSync { + NetworkContent::loading_ui(ui, Some(t!("network_mining.loading"))); + return; + } + + // Show stratum server setup when mining server is not running. + let stratum_stats = Node::get_stratum_stats(); + if !stratum_stats.is_running { + ScrollArea::vertical() + .id_salt("stratum_setup_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(1.0); + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + self.stratum_server_setup.ui(ui, cb); + }); + }); + return; + } + + ui.add_space(1.0); + + // Show stratum mining server info. + View::sub_title( + ui, + format!("{} {}", HARD_DRIVES, t!("network_mining.server")), + ); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + let (stratum_addr, stratum_port) = NodeConfig::get_stratum_address(); + View::label_box( + ui, + format!("{}:{}", stratum_addr, stratum_port), + t!("network_mining.address"), + [true, false, true, false], + ); + }); + columns[1].vertical_centered(|ui| { + View::label_box( + ui, + self.stratum_server_setup + .wallet_name + .clone() + .unwrap_or("-".to_string()), + t!("network_mining.rewards_wallet"), + [false, true, false, true], + ); + }); + }); + ui.add_space(4.0); + + // Show network info. + View::sub_title(ui, format!("{} {}", POLYGON, t!("network.self"))); + ui.columns(3, |columns| { + columns[0].vertical_centered(|ui| { + let difficulty = if stratum_stats.network_difficulty > 0 { + stratum_stats.network_difficulty.to_string() + } else { + "-".into() + }; + View::label_box( + ui, + difficulty, + t!("network_node.difficulty"), + [true, false, true, false], + ); + }); + columns[1].vertical_centered(|ui| { + let block_height = if stratum_stats.block_height > 0 { + stratum_stats.block_height.to_string() + } else { + "-".into() + }; + View::label_box( + ui, + block_height, + t!("network_node.header"), + [false, false, false, false], + ); + }); + columns[2].vertical_centered(|ui| { + let hashrate = if stratum_stats.network_hashrate > 0.0 { + format!("{:.*}", 2, stratum_stats.network_hashrate) + } else { + "-".into() + }; + View::label_box( + ui, + hashrate, + t!("network_mining.hashrate", "bits" => stratum_stats.edge_bits), + [false, true, false, true], + ); + }); + }); + ui.add_space(4.0); + + // Show mining info. + View::sub_title(ui, format!("{} {}", CPU, t!("network_mining.miners"))); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + View::label_box( + ui, + stratum_stats.num_workers.to_string(), + t!("network_mining.devices"), + [true, false, true, false], + ); + }); + + columns[1].vertical_centered(|ui| { + View::label_box( + ui, + stratum_stats.blocks_found.to_string(), + t!("network_mining.blocks_found"), + [false, true, false, true], + ); + }); + }); + ui.add_space(4.0); + + // Show workers stats or info text when possible. + let workers_size = stratum_stats.worker_stats.len(); + if workers_size != 0 && stratum_stats.num_workers > 0 { + ui.add_space(4.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(4.0); + ScrollArea::vertical() + .id_salt("stratum_workers_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show_rows(ui, WORKER_ITEM_HEIGHT, workers_size, |ui, row_range| { + for index in row_range { + // Add space before the first item. + if index == 0 { + ui.add_space(4.0); + } + let worker = stratum_stats.worker_stats.get(index).unwrap(); + let item_rounding = View::item_rounding(index, workers_size, false); + worker_item_ui(ui, worker, item_rounding); + } + }); + } else if ui.available_height() > 142.0 { + View::center_content(ui, 142.0, |ui| { + ui.label( + RichText::new(t!("network_mining.info", "settings" => FADERS)) + .size(16.0) + .color(Colors::inactive_text()), + ); + }); + } + } +} + +/// Height of Stratum server worker list item. +const WORKER_ITEM_HEIGHT: f32 = 76.0; + +/// Draw worker statistics item. +fn worker_item_ui(ui: &mut egui::Ui, ws: &WorkerStats, rounding: CornerRadius) { + ui.horizontal_wrapped(|ui| { + ui.vertical_centered_justified(|ui| { + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(WORKER_ITEM_HEIGHT); + ui.painter().rect( + rect, + rounding, + Colors::white_or_black(false), + View::item_stroke(), + StrokeKind::Outside, + ); + + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.add_space(5.0); + + // Draw worker connection status. + let (status_text, status_icon, status_color) = match ws.is_connected { + true => ( + t!("network_mining.connected"), + PLUGS_CONNECTED, + Colors::white_or_black(true), + ), + false => ( + t!("network_mining.disconnected"), + PLUGS, + Colors::inactive_text(), + ), + }; + let status_line_text = format!("{} {} {}", status_icon, ws.id, status_text); + ui.heading( + RichText::new(status_line_text) + .color(status_color) + .size(17.0), + ); + ui.add_space(2.0); + }); + ui.horizontal(|ui| { + ui.add_space(6.0); + + // Draw difficulty. + let diff_text = format!("{} {}", BARBELL, ws.pow_difficulty); + ui.heading( + RichText::new(diff_text) + .color(Colors::title(false)) + .size(16.0), + ); + ui.add_space(6.0); + + // Draw accepted shares. + let accepted_text = format!("{} {}", FOLDER_SIMPLE_PLUS, ws.num_accepted); + ui.heading( + RichText::new(accepted_text) + .color(Colors::green()) + .size(16.0), + ); + ui.add_space(6.0); + + // Draw rejected shares. + let rejected_text = format!("{} {}", FOLDER_SIMPLE_MINUS, ws.num_rejected); + ui.heading(RichText::new(rejected_text).color(Colors::red()).size(16.0)); + ui.add_space(6.0); + + // Draw stale shares. + let stale_text = format!("{} {}", FOLDER_DASHED, ws.num_stale); + ui.heading(RichText::new(stale_text).color(Colors::gray()).size(16.0)); + ui.add_space(6.0); + + // Draw blocks found. + let blocks_found_text = format!("{} {}", CUBE, ws.num_blocks_found); + ui.heading( + RichText::new(blocks_found_text) + .color(Colors::title(false)) + .size(16.0), + ); + }); + ui.horizontal(|ui| { + ui.add_space(6.0); + + // Draw block time + let seen_ts = ws + .last_seen + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let seen_time = View::format_time(seen_ts as i64); + let seen_text = format!("{} {}", CLOCK_AFTERNOON, seen_time); + ui.heading(RichText::new(seen_text).color(Colors::gray()).size(16.0)); + }); + }); + }); +} diff --git a/src/gui/views/network/mod.rs b/src/gui/views/network/mod.rs new file mode 100644 index 00000000..aef190eb --- /dev/null +++ b/src/gui/views/network/mod.rs @@ -0,0 +1,37 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +mod metrics; +pub use metrics::*; + +mod mining; +pub use mining::*; + +mod settings; +pub use settings::*; + +mod node; +pub use node::*; + +mod setup; +pub use setup::*; + +mod content; +pub use content::*; + +mod connections; +pub use connections::*; + +pub mod modals; +pub mod types; diff --git a/src/gui/views/network/modals/ext_conn.rs b/src/gui/views/network/modals/ext_conn.rs new file mode 100644 index 00000000..7a16f46d --- /dev/null +++ b/src/gui/views/network/modals/ext_conn.rs @@ -0,0 +1,273 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; +use url::Url; + +use crate::gui::Colors; +use crate::gui::icons::SCAN; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::types::ShareConnection; +use crate::gui::views::{CameraContent, Modal, TextEdit, View}; +use crate::wallet::{ConnectionsConfig, ExternalConnection}; + +/// Content to create or update external wallet connection. +pub struct ExternalConnectionModal { + /// Flag to check if content was just rendered. + first_draw: bool, + + /// Editing external connection identifier. + id: Option, + + /// External connection URL. + url_edit: String, + /// Flag to show URL format error. + url_error: bool, + + // /// External connection username. + // username_edit: String, + /// External connection API secret. + secret_edit: String, + + /// QR code scanner content. + scan_qr_content: Option, +} + +impl ExternalConnectionModal { + /// Network [`Modal`] identifier. + pub const NETWORK_ID: &'static str = "net_ext_conn_modal"; + /// Wallet [`Modal`] identifier. + pub const WALLET_ID: &'static str = "wallet_ext_conn_modal"; + + /// Create new instance from optional provided connection to update. + pub fn new(conn: Option) -> Self { + let (url_edit, secret_edit, id) = if let Some(c) = conn { + // let username = c.username.unwrap_or("grin".to_string()); + let secret = c.secret.unwrap_or("".to_string()); + (c.url, secret, Some(c.id)) + } else { + ("".to_string(), "".to_string(), None) + }; + Self { + first_draw: true, + url_edit, + url_error: false, + secret_edit, + id, + scan_qr_content: None, + } + } + + /// Draw external connection [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + cb: &dyn PlatformCallbacks, + modal: &Modal, + on_save: impl Fn(ExternalConnection), + ) { + // Show QR code scanner content. + if let Some(scan_content) = self.scan_qr_content.as_mut() { + if let Some(result) = scan_content.qr_scan_result() { + cb.stop_camera(); + modal.enable_closing(); + self.scan_qr_content = None; + // Parse scan result. + if let Ok(c) = serde_json::from_str::(&result.text()) { + let ext_conn = ExternalConnection::new(c.url, Some(c.username), Some(c.secret)); + ConnectionsConfig::add_ext_conn(ext_conn.clone()); + ExternalConnection::check(Some(ext_conn.id), ui.ctx()); + Modal::close(); + } + } else { + scan_content.ui(ui, cb); + } + ui.add_space(8.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + // Show buttons to close modal or scanner. + ui.columns(2, |cols| { + cols[0].vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + cb.stop_camera(); + self.scan_qr_content = None; + Modal::close(); + }); + }); + cols[1].vertical_centered_justified(|ui| { + View::button(ui, t!("back"), Colors::white_or_black(false), || { + cb.stop_camera(); + self.scan_qr_content = None; + modal.enable_closing(); + }); + }); + }); + ui.add_space(6.0); + return; + } + // Add connection button callback. + let on_add = |ui: &mut egui::Ui, m: &mut ExternalConnectionModal| { + let url = if !m.url_edit.starts_with("http") { + format!("https://{}", m.url_edit) + } else { + m.url_edit.clone() + }; + let error = Url::parse(url.trim()).is_err(); + m.url_error = error; + if !error { + let username = if m.secret_edit.is_empty() { + Some("grin".to_string()) + } else { + Some(m.secret_edit.clone()) + }; + let secret = if m.secret_edit.is_empty() { + None + } else { + Some(m.secret_edit.clone()) + }; + // Update or create new connection. + let mut ext_conn = ExternalConnection::new(url, username, secret); + if let Some(id) = m.id { + ext_conn.id = id; + } + ConnectionsConfig::add_ext_conn(ext_conn.clone()); + ExternalConnection::check(Some(ext_conn.id), ui.ctx()); + on_save(ext_conn); + + // Close modal. + m.url_edit = "".to_string(); + m.secret_edit = "".to_string(); + m.url_error = false; + Modal::close(); + } + }; + + ui.vertical_centered(|ui| { + ui.add_space(6.0); + ui.label( + RichText::new(t!("wallets.node_url")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw node URL text edit. + let url_edit_id = Id::from(modal.id).with(self.id).with("node_url"); + let mut url_edit = TextEdit::new(url_edit_id).paste().focus(self.first_draw); + let url_edit_before = self.url_edit.clone(); + url_edit.ui(ui, &mut self.url_edit, cb); + if self.url_edit != url_edit_before { + self.url_error = false; + } + + // ui.add_space(8.0); + // ui.label(RichText::new(t!("wallets.name")) + // .size(17.0) + // .color(Colors::gray())); + // ui.add_space(8.0); + // + // // Draw node username text edit (disabled by default). + // let username_edit_id = Id::from(modal.id).with(self.id).with("node_username"); + // let mut username_edit = TextEdit::new(username_edit_id).focus(false).disable(); + // username_edit.ui(ui, &mut self.username_edit, cb); + + ui.add_space(8.0); + ui.label( + RichText::new(t!("wallets.node_secret")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw node API secret text edit. + let secret_edit_id = Id::from(modal.id).with(self.id).with("node_secret"); + let mut secret_edit = TextEdit::new(secret_edit_id) + .h_center() + .password() + .paste() + .focus(false); + if url_edit.enter_pressed { + secret_edit.focus_request(); + } + secret_edit.ui(ui, &mut self.secret_edit, cb); + if secret_edit.enter_pressed { + on_add(ui, self); + } + + // Show error when specified URL is not valid. + if self.url_error { + ui.add_space(12.0); + ui.label( + RichText::new(t!("wallets.invalid_url")) + .size(17.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + + let scan_text = format!("{} {}", SCAN, t!("scan")); + View::button(ui, scan_text, Colors::white_or_black(false), || { + modal.disable_closing(); + self.scan_qr_content = Some(CameraContent::default()); + cb.start_camera(); + }); + }); + + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + self.url_edit = "".to_string(); + self.secret_edit = "".to_string(); + self.url_error = false; + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button_ui( + ui, + if self.id.is_some() { + t!("modal.save") + } else { + t!("modal.add") + }, + Colors::white_or_black(false), + |ui| { + (on_add)(ui, self); + }, + ); + }); + }); + ui.add_space(6.0); + }); + + self.first_draw = false; + } +} diff --git a/src/gui/views/network/modals/mod.rs b/src/gui/views/network/modals/mod.rs new file mode 100644 index 00000000..02d0ef22 --- /dev/null +++ b/src/gui/views/network/modals/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +mod ext_conn; +pub use ext_conn::*; + +mod share_conn; +pub use share_conn::*; diff --git a/src/gui/views/network/modals/share_conn.rs b/src/gui/views/network/modals/share_conn.rs new file mode 100644 index 00000000..dea12a83 --- /dev/null +++ b/src/gui/views/network/modals/share_conn.rs @@ -0,0 +1,57 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::types::ShareConnection; +use crate::gui::views::{Modal, QrCodeContent, View}; + +/// [`Modal`] content to share connection with QR code. +pub struct ShareConnectionContent { + /// QR code content. + pub qr_details_content: QrCodeContent, +} + +impl ShareConnectionContent { + /// Create new content instance from connection details. + pub fn new(details: ShareConnection) -> Result { + let details = serde_json::to_string_pretty(&details)?; + let c = Self { + qr_details_content: QrCodeContent::new(details, false).hide_text().no_copy(), + }; + Ok(c) + } + + /// Draw QR code content. + pub fn ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let dark_theme = AppConfig::dark_theme().unwrap_or(false); + // Set light theme for better scanning. + AppConfig::set_dark_theme(false); + modal.set_background_color(Colors::FILL_DEEP); + crate::setup_visuals(ui.ctx()); + // Draw QR code content. + ui.add_space(6.0); + self.qr_details_content.ui(ui, cb); + ui.vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + Modal::close(); + }); + }); + ui.add_space(6.0); + // Set color theme back. + AppConfig::set_dark_theme(dark_theme); + crate::setup_visuals(ui.ctx()); + } +} diff --git a/src/gui/views/network/node.rs b/src/gui/views/network/node.rs new file mode 100644 index 00000000..816e51df --- /dev/null +++ b/src/gui/views/network/node.rs @@ -0,0 +1,254 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{CornerRadius, RichText, ScrollArea, StrokeKind}; +use grin_servers::PeerStats; + +use crate::gui::Colors; +use crate::gui::icons::{AT, CUBE, DEVICES, FLOW_ARROW, HANDSHAKE, PACKAGE, SHARE_NETWORK}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::types::{NodeTab, NodeTabType}; +use crate::gui::views::{Content, View}; +use crate::node::{Node, NodeConfig}; + +/// Integrated node tab content. +#[derive(Default)] +pub struct NetworkNode; + +impl NodeTab for NetworkNode { + fn get_type(&self) -> NodeTabType { + NodeTabType::Info + } + + fn tab_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + ScrollArea::vertical() + .id_salt("integrated_node_info_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(2.0); + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + // Show node stats content. + node_stats_ui(ui); + }); + }); + } +} + +/// Draw node statistics content. +fn node_stats_ui(ui: &mut egui::Ui) { + let server_stats = Node::get_stats(); + let stats = server_stats.as_ref().unwrap(); + + // Show header info. + View::sub_title(ui, format!("{} {}", FLOW_ARROW, t!("network_node.header"))); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + View::label_box( + ui, + stats.header_stats.last_block_h.to_string(), + t!("network_node.hash"), + [true, false, false, false], + ); + }); + columns[1].vertical_centered(|ui| { + View::label_box( + ui, + stats.header_stats.height.to_string(), + t!("network_node.height"), + [false, true, false, false], + ); + }); + }); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + View::label_box( + ui, + stats.header_stats.total_difficulty.to_string(), + t!("network_node.difficulty"), + [false, false, true, false], + ); + }); + columns[1].vertical_centered(|ui| { + let h_ts = stats.header_stats.latest_timestamp.timestamp(); + let h_time = View::format_time(h_ts); + View::label_box( + ui, + h_time, + t!("network_node.time"), + [false, false, false, true], + ); + }); + }); + ui.add_space(5.0); + + // Show block info. + View::sub_title(ui, format!("{} {}", CUBE, t!("network_node.block"))); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + View::label_box( + ui, + stats.chain_stats.last_block_h.to_string(), + t!("network_node.hash"), + [true, false, false, false], + ); + }); + columns[1].vertical_centered(|ui| { + View::label_box( + ui, + stats.chain_stats.height.to_string(), + t!("network_node.height"), + [false, true, false, false], + ); + }); + }); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + View::label_box( + ui, + stats.chain_stats.total_difficulty.to_string(), + t!("network_node.difficulty"), + [false, false, true, false], + ); + }); + columns[1].vertical_centered(|ui| { + let b_ts = stats.chain_stats.latest_timestamp.timestamp(); + let b_time = View::format_time(b_ts); + View::label_box( + ui, + b_time, + t!("network_node.time"), + [false, false, false, true], + ); + }); + }); + ui.add_space(5.0); + + // Show data info. + View::sub_title(ui, format!("{} {}", SHARE_NETWORK, t!("network_node.data"))); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + let tx_stat = match &stats.tx_stats { + None => "0 (0)".to_string(), + Some(tx) => format!("{} ({})", tx.tx_pool_size, tx.tx_pool_kernels), + }; + View::label_box( + ui, + tx_stat, + t!("network_node.main_pool"), + [true, false, false, false], + ); + }); + columns[1].vertical_centered(|ui| { + let stem_tx_stat = match &stats.tx_stats { + None => "0 (0)".to_string(), + Some(stx) => format!("{} ({})", stx.stem_pool_size, stx.stem_pool_kernels), + }; + View::label_box( + ui, + stem_tx_stat, + t!("network_node.stem_pool"), + [false, true, false, false], + ); + }); + }); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + View::label_box( + ui, + stats.disk_usage_gb.to_string(), + t!("network_node.size"), + [false, false, true, false], + ); + }); + columns[1].vertical_centered(|ui| { + let peers_txt = format!( + "{} ({})", + stats.peer_count, + NodeConfig::get_max_outbound_peers() + ); + View::label_box( + ui, + peers_txt, + t!("network_node.peers"), + [false, false, false, true], + ); + }); + }); + ui.add_space(5.0); + + // Show peer stats when available. + if stats.peer_count > 0 { + View::sub_title(ui, format!("{} {}", HANDSHAKE, t!("network_node.peers"))); + let peers = &stats.peer_stats; + for (index, ps) in peers.iter().enumerate() { + peer_item_ui(ui, ps, View::item_rounding(index, peers.len(), false)); + } + ui.add_space(5.0); + } +} + +const PEER_ITEM_HEIGHT: f32 = 77.0; + +/// Draw connected peer info item. +fn peer_item_ui(ui: &mut egui::Ui, peer: &PeerStats, r: CornerRadius) { + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(PEER_ITEM_HEIGHT); + ui.allocate_ui(rect.size(), |ui| { + ui.vertical(|ui| { + ui.add_space(4.0); + + // Draw round background. + ui.painter().rect( + rect, + r, + Colors::fill(), + View::item_stroke(), + StrokeKind::Outside, + ); + + // Draw IP address. + ui.horizontal(|ui| { + ui.add_space(7.0); + ui.label( + RichText::new(&peer.addr) + .color(Colors::white_or_black(true)) + .size(17.0), + ); + }); + // Draw difficulty and height. + ui.horizontal(|ui| { + ui.add_space(6.0); + let diff_text = format!( + "{} {} {} {}", + PACKAGE, peer.total_difficulty, AT, peer.height + ); + ui.label( + RichText::new(diff_text) + .color(Colors::title(false)) + .size(15.0), + ); + }); + // Draw user-agent. + ui.horizontal(|ui| { + ui.add_space(6.0); + let agent_text = format!("{} {}", DEVICES, &peer.user_agent); + ui.label(RichText::new(agent_text).color(Colors::gray()).size(15.0)); + }); + + ui.add_space(3.0); + }); + }); +} diff --git a/src/gui/views/network/settings.rs b/src/gui/views/network/settings.rs new file mode 100644 index 00000000..17606c2d --- /dev/null +++ b/src/gui/views/network/settings.rs @@ -0,0 +1,313 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{RichText, ScrollArea}; + +use crate::gui::Colors; +use crate::gui::icons::{ARROW_COUNTER_CLOCKWISE, TRASH}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::setup::{ + DandelionSetup, NodeSetup, P2PSetup, PoolSetup, StratumSetup, +}; +use crate::gui::views::network::types::{NodeTab, NodeTabType}; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{Content, Modal, View}; +use crate::node::{Node, NodeConfig}; + +/// Integrated node settings tab content. +pub struct NetworkSettings { + /// Integrated node general setup content. + node: NodeSetup, + /// P2P server setup content. + p2p: P2PSetup, + /// Stratum server setup content. + stratum: StratumSetup, + /// Pool setup content. + pool: PoolSetup, + /// Dandelion server setup content. + dandelion: DandelionSetup, + + /// Flag to check if reset of data was called. + data_reset: bool, +} + +/// Identifier for settings reset confirmation [`Modal`]. +pub const RESET_SETTINGS_CONFIRMATION_MODAL: &'static str = "reset_settings_confirmation"; + +impl Default for NetworkSettings { + fn default() -> Self { + Self { + node: NodeSetup::default(), + p2p: P2PSetup::default(), + stratum: StratumSetup::default(), + pool: PoolSetup::default(), + dandelion: DandelionSetup::default(), + data_reset: false, + } + } +} + +impl ContentContainer for NetworkSettings { + fn modal_ids(&self) -> Vec<&'static str> { + vec![RESET_SETTINGS_CONFIRMATION_MODAL] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, _: &dyn PlatformCallbacks) { + match modal.id { + RESET_SETTINGS_CONFIRMATION_MODAL => reset_settings_confirmation_modal(ui), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + ScrollArea::vertical() + .id_salt("node_settings_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(1.0); + ui.vertical_centered(|ui| { + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + // Draw node setup section. + self.node.ui(ui, cb); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(4.0); + + // Draw P2P server setup section. + self.p2p.ui(ui, cb); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(4.0); + + // Draw Stratum server setup section. + self.stratum.ui(ui, cb); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(4.0); + + // Draw pool setup section. + self.pool.ui(ui, cb); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(4.0); + + // Draw Dandelion server setup section. + self.dandelion.ui(ui, cb); + + // Draw content to reset the data. + if !Node::is_restarting() && !self.data_reset { + ui.add_space(4.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + self.reset_data_ui(ui); + } + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + // Draw reset settings content. + reset_settings_ui(ui); + }); + }); + }); + } +} + +impl NodeTab for NetworkSettings { + fn get_type(&self) -> NodeTabType { + NodeTabType::Settings + } + + fn tab_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + self.ui(ui, cb); + } +} + +impl NetworkSettings { + /// Reminder to restart enabled node to show on edit setting at [`Modal`]. + pub fn node_restart_required_ui(ui: &mut egui::Ui) { + if Node::is_running() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.restart_node_required")) + .size(16.0) + .color(Colors::green()), + ); + } + } + + /// Draw IP addresses as radio buttons. + pub fn ip_addrs_ui( + ui: &mut egui::Ui, + saved_ip: &String, + ips: &Vec, + on_change: impl FnOnce(&String), + ) { + let mut selected_ip = saved_ip; + + // Set first IP address as current if saved is not present at system. + if !ips.contains(saved_ip) { + selected_ip = ips.get(0).unwrap(); + } + + ui.add_space(2.0); + + // Show available IP addresses on the system. + let _ = ips + .chunks(2) + .map(|x| { + if x.len() == 2 { + ui.columns(2, |columns| { + let ip_left = x.get(0).unwrap(); + columns[0].vertical_centered(|ui| { + View::radio_value(ui, &mut selected_ip, ip_left, ip_left.to_string()); + }); + let ip_right = x.get(1).unwrap(); + columns[1].vertical_centered(|ui| { + View::radio_value(ui, &mut selected_ip, ip_right, ip_right.to_string()); + }) + }); + } else { + let ip = x.get(0).unwrap(); + View::radio_value(ui, &mut selected_ip, ip, ip.to_string()); + } + ui.add_space(12.0); + }) + .collect::>(); + + if saved_ip != selected_ip { + on_change(&selected_ip.to_string()); + } + } + + /// Show message when IP addresses are not available at system. + pub fn no_ip_address_ui(ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network.no_ips")) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(6.0); + }); + } + + /// Draw content to reset data. + fn reset_data_ui(&mut self, ui: &mut egui::Ui) { + ui.add_space(4.0); + View::colored_text_button( + ui, + format!("{} {}", TRASH, t!("network_settings.reset_data")), + Colors::red(), + Colors::white_or_black(false), + || { + Node::reset_data(false); + self.data_reset = true; + }, + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("network_settings.reset_data_desc")) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(4.0); + } +} + +/// Draw button to reset integrated node settings to default values. +fn reset_settings_ui(ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.reset_settings_desc")) + .size(16.0) + .color(Colors::text(false)), + ); + ui.add_space(8.0); + let button_text = format!( + "{} {}", + ARROW_COUNTER_CLOCKWISE, + t!("network_settings.reset_settings") + ); + View::action_button(ui, button_text, || { + // Show modal to confirm settings reset. + Modal::new(RESET_SETTINGS_CONFIRMATION_MODAL) + .position(ModalPosition::Center) + .title(t!("confirmation")) + .show(); + }); + + // Show reminder to restart enabled node. + if Node::is_running() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.restart_node_required")) + .size(16.0) + .color(Colors::gray()), + ); + } + ui.add_space(10.0); +} + +/// Confirmation to reset settings to default values. +fn reset_settings_confirmation_modal(ui: &mut egui::Ui) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let reset_text = format!("{}?", t!("network_settings.reset_settings_desc")); + ui.label( + RichText::new(reset_text) + .size(17.0) + .color(Colors::text(false)), + ); + ui.add_space(8.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("network_settings.reset"), + Colors::white_or_black(false), + || { + NodeConfig::reset_to_default(); + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + }); + ui.add_space(6.0); + }); +} diff --git a/src/gui/views/network/setup/dandelion.rs b/src/gui/views/network/setup/dandelion.rs new file mode 100644 index 00000000..632c5920 --- /dev/null +++ b/src/gui/views/network/setup/dandelion.rs @@ -0,0 +1,499 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; + +use crate::gui::Colors; +use crate::gui::icons::{CLOCK_COUNTDOWN, GRAPH, TIMER, WATCH}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::NetworkSettings; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{Modal, TextEdit, View}; +use crate::node::NodeConfig; + +/// Dandelion server setup section content. +pub struct DandelionSetup { + /// Epoch duration value in seconds. + epoch_edit: String, + + /// Embargo expiration time value in seconds to fluff and broadcast if tx not seen on network. + embargo_edit: String, + + /// Aggregation period value in seconds. + aggregation_edit: String, + + /// Stem phase probability value (stem 90% of the time, fluff 10% of the time by default). + stem_prob_edit: String, +} + +/// Identifier epoch duration value [`Modal`]. +pub const EPOCH_MODAL: &'static str = "epoch_secs"; +/// Identifier for embargo expiration time value [`Modal`]. +pub const EMBARGO_MODAL: &'static str = "embargo_secs"; +/// Identifier for aggregation period value [`Modal`]. +pub const AGGREGATION_MODAL: &'static str = "aggregation_secs"; +/// Identifier for Stem phase probability value [`Modal`]. +pub const STEM_PROBABILITY_MODAL: &'static str = "stem_probability"; + +impl Default for DandelionSetup { + fn default() -> Self { + Self { + epoch_edit: NodeConfig::get_dandelion_epoch(), + embargo_edit: NodeConfig::get_reorg_cache_period(), + aggregation_edit: NodeConfig::get_dandelion_aggregation(), + stem_prob_edit: NodeConfig::get_stem_probability(), + } + } +} + +impl ContentContainer for DandelionSetup { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + EPOCH_MODAL, + EMBARGO_MODAL, + AGGREGATION_MODAL, + STEM_PROBABILITY_MODAL, + ] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + EPOCH_MODAL => self.epoch_modal(ui, modal, cb), + EMBARGO_MODAL => self.embargo_modal(ui, modal, cb), + AGGREGATION_MODAL => self.aggregation_modal(ui, modal, cb), + STEM_PROBABILITY_MODAL => self.stem_prob_modal(ui, modal, cb), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + View::sub_title(ui, format!("{} {}", GRAPH, "Dandelion")); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + ui.vertical_centered(|ui| { + // Show epoch duration setup. + self.epoch_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show embargo expiration time setup. + self.embargo_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show aggregation period setup. + self.aggregation_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show Stem phase probability setup. + self.stem_prob_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Show setup to always stem our txs. + let always_stem = NodeConfig::always_stem_our_txs(); + View::checkbox(ui, always_stem, t!("network_settings.stem_txs"), || { + NodeConfig::toggle_always_stem_our_txs(); + }); + ui.add_space(6.0); + }); + } +} + +impl DandelionSetup { + /// Draw epoch duration setup content. + fn epoch_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.epoch_duration")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let epoch = NodeConfig::get_dandelion_epoch(); + View::button( + ui, + format!("{} {}", WATCH, &epoch), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.epoch_edit = epoch; + // Show epoch setup modal. + Modal::new(EPOCH_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw epoch duration [`Modal`] content. + fn epoch_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + // Save button callback. + let on_save = |c: &mut DandelionSetup| { + if let Ok(epoch) = c.epoch_edit.parse::() { + NodeConfig::save_dandelion_epoch(epoch); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.epoch_duration")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw epoch text edit. + let mut epoch_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + epoch_edit.ui(ui, &mut self.epoch_edit, cb); + if epoch_edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.epoch_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw embargo expiration time setup content. + fn embargo_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.embargo_timer")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let embargo = NodeConfig::get_dandelion_embargo(); + View::button( + ui, + format!("{} {}", TIMER, &embargo), + Colors::white_or_black(false), + || { + self.embargo_edit = embargo; + // Show embargo setup modal. + Modal::new(EMBARGO_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw epoch duration [`Modal`] content. + fn embargo_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + // Save button callback. + let on_save = |c: &mut DandelionSetup| { + if let Ok(embargo) = c.embargo_edit.parse::() { + NodeConfig::save_dandelion_embargo(embargo); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.embargo_timer")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw embargo text edit. + let mut embargo_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + embargo_edit.ui(ui, &mut self.embargo_edit, cb); + if embargo_edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.embargo_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw aggregation period setup content. + fn aggregation_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.aggregation_period")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let ag = NodeConfig::get_dandelion_aggregation(); + View::button( + ui, + format!("{} {}", CLOCK_COUNTDOWN, &ag), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.aggregation_edit = ag; + // Show aggregation setup modal. + Modal::new(AGGREGATION_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw aggregation period [`Modal`] content. + fn aggregation_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + // Save button callback. + let on_save = |c: &mut DandelionSetup| { + if let Ok(embargo) = c.aggregation_edit.parse::() { + NodeConfig::save_dandelion_aggregation(embargo); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.aggregation_period")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw aggregation period text edit. + let mut aggregation_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + aggregation_edit.ui(ui, &mut self.aggregation_edit, cb); + if aggregation_edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.aggregation_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw stem phase probability setup content. + fn stem_prob_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.stem_probability")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let stem_prob = NodeConfig::get_stem_probability(); + View::button( + ui, + format!("{}%", &stem_prob), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.stem_prob_edit = stem_prob; + // Show stem probability setup modal. + Modal::new(STEM_PROBABILITY_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw stem phase probability [`Modal`] content. + fn stem_prob_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + // Save button callback. + let on_save = |c: &mut DandelionSetup| { + if let Ok(prob) = c.stem_prob_edit.parse::() { + NodeConfig::save_stem_probability(prob); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.stem_probability")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw stem phase probability text edit. + let mut stem_prob_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + stem_prob_edit.ui(ui, &mut self.stem_prob_edit, cb); + if stem_prob_edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.stem_prob_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } +} diff --git a/src/gui/views/network/setup/mod.rs b/src/gui/views/network/setup/mod.rs new file mode 100644 index 00000000..a269ef43 --- /dev/null +++ b/src/gui/views/network/setup/mod.rs @@ -0,0 +1,28 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +mod node; +pub use node::NodeSetup; + +mod p2p; +pub use p2p::P2PSetup; + +mod pool; +pub use pool::PoolSetup; + +mod dandelion; +pub use dandelion::DandelionSetup; + +mod stratum; +pub use stratum::StratumSetup; diff --git a/src/gui/views/network/setup/node.rs b/src/gui/views/network/setup/node.rs new file mode 100644 index 00000000..1f030405 --- /dev/null +++ b/src/gui/views/network/setup/node.rs @@ -0,0 +1,757 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use eframe::emath::Align; +use eframe::epaint::{RectShape, StrokeKind}; +use egui::{CursorIcon, Id, Layout, RichText, Sense, UiBuilder}; +use grin_core::global::ChainTypes; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{ + CLOCK_CLOCKWISE, COMPUTER_TOWER, FOLDERS, PLUG, POWER, SHIELD, SHIELD_SLASH, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::NetworkContent; +use crate::gui::views::network::settings::NetworkSettings; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{FilePickContent, FilePickContentType, Modal, TextEdit, View}; +use crate::node::{Node, NodeConfig}; + +/// Integrated node general setup section content. +pub struct NodeSetup { + /// Data path value value for [`Modal`]. + data_path_edit: String, + /// Button to pick directory for chain data. + pick_data_dir: FilePickContent, + + /// IP Addresses available at system. + available_ips: Vec, + + /// API port value. + api_port_edit: String, + /// Flag to check if API port is available. + api_port_available_edit: bool, + + /// Flag to check if API port from saved config value is available. + is_api_port_available: bool, + + /// Secret edit value for modal. + secret_edit: String, + + /// Future Time Limit value. + ftl_edit: String, +} + +/// Identifier for chain data path value [`Modal`]. +const DATA_PATH_MODAL: &'static str = "node_data_path"; +/// Identifier for API port value [`Modal`]. +const API_PORT_MODAL: &'static str = "node_api_port"; +/// Identifier for API secret value [`Modal`]. +const API_SECRET_MODAL: &'static str = "node_api_secret"; +/// Identifier for Foreign API secret value [`Modal`]. +const FOREIGN_API_SECRET_MODAL: &'static str = "node_foreign_api_secret"; +/// Identifier for FTL value [`Modal`]. +const FTL_MODAL: &'static str = "node_ftl"; + +impl Default for NodeSetup { + fn default() -> Self { + let (api_ip, api_port) = NodeConfig::get_api_ip_port(); + let is_api_port_available = NodeConfig::is_api_port_available(&api_ip, &api_port); + Self { + data_path_edit: NodeConfig::get_chain_data_path(), + pick_data_dir: FilePickContent::new(FilePickContentType::ItemButton( + View::item_rounding(0, 1, true), + )) + .no_parse() + .pick_folder(), + available_ips: NodeConfig::get_ip_addrs(), + api_port_edit: api_port, + api_port_available_edit: is_api_port_available, + is_api_port_available, + secret_edit: "".to_string(), + ftl_edit: NodeConfig::get_ftl(), + } + } +} + +impl ContentContainer for NodeSetup { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + DATA_PATH_MODAL, + API_PORT_MODAL, + API_SECRET_MODAL, + FOREIGN_API_SECRET_MODAL, + FTL_MODAL, + ] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + DATA_PATH_MODAL => self.data_path_edit_modal_ui(ui, cb), + API_PORT_MODAL => self.api_port_modal(ui, modal, cb), + API_SECRET_MODAL => self.secret_modal(ui, modal, cb), + FOREIGN_API_SECRET_MODAL => self.secret_modal(ui, modal, cb), + FTL_MODAL => self.ftl_modal(ui, modal, cb), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + View::sub_title( + ui, + format!("{} {}", COMPUTER_TOWER, t!("network_settings.server")), + ); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + // Show chain type setup. + Self::chain_type_ui(ui); + ui.add_space(2.0); + + // Show loading indicator or controls to stop/start/restart node. + if Node::is_stopping() + || Node::is_restarting() + || Node::is_starting() + || Node::data_dir_changing() + { + ui.vertical_centered(|ui| { + ui.add_space(8.0); + View::small_loading_spinner(ui); + ui.add_space(2.0); + }); + } else { + if Node::is_running() { + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(6.0, 0.0); + ui.add_space(6.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::action_button(ui, t!("network_settings.disable"), || { + Node::stop(false); + }); + }); + columns[1].vertical_centered_justified(|ui| { + View::action_button(ui, t!("network_settings.restart"), || { + Node::restart(); + }); + }); + }); + }); + } else { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let enable_text = format!("{} {}", POWER, t!("network_settings.enable")); + View::action_button(ui, enable_text, || { + Node::start(); + }); + }); + } + } + + // Autorun node setup. + ui.vertical_centered(|ui| { + ui.add_space(6.0); + NetworkContent::autorun_node_ui(ui); + if Node::is_running() { + ui.add_space(2.0); + ui.label( + RichText::new(t!("network_settings.restart_node_required")) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(4.0); + } + }); + ui.add_space(6.0); + + // Show data location selection for Desktop when it already started or turned off. + if !Node::is_restarting() + && !Node::is_stopping() + && !Node::is_starting() + && View::is_desktop() + { + self.data_dir_ui(ui, cb); + ui.add_space(6.0); + } + + if self.available_ips.is_empty() { + // Show message when IP addresses are not available on the system. + NetworkSettings::no_ip_address_ui(ui); + } else { + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.api_ip")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + // Show API IP addresses to select. + let (api_ip, api_port) = NodeConfig::get_api_ip_port(); + NetworkSettings::ip_addrs_ui(ui, &api_ip, &self.available_ips, |selected_ip| { + let api_available = NodeConfig::is_api_port_available(selected_ip, &api_port); + self.is_api_port_available = api_available; + NodeConfig::save_api_address(selected_ip, &api_port); + }); + // Show API port setup. + self.api_port_setup_ui(ui); + // Show API secret setup. + self.secret_ui(API_SECRET_MODAL, ui); + ui.add_space(12.0); + // Show Foreign API secret setup. + self.secret_ui(FOREIGN_API_SECRET_MODAL, ui); + ui.add_space(6.0); + }); + } + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + ui.vertical_centered(|ui| { + // Show FTL setup. + self.ftl_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Validation setup. + self.validation_mode_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Archive mode setup. + self.archive_mode_ui(ui); + }); + } +} + +impl NodeSetup { + /// Draw content to change chain data directory. + fn data_dir_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(56.0); + let r = View::item_rounding(0, 1, false); + let bg = Colors::fill_lite(); + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + ui.allocate_ui_with_layout( + rect.size(), + Layout::right_to_left(Align::Center), + |ui| { + self.pick_data_dir.ui(ui, cb, |path| { + Node::change_data_dir(path); + }); + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(4.0); + let path = NodeConfig::get_chain_data_path(); + View::ellipsize_text(ui, path, 18.0, Colors::title(false)); + ui.add_space(1.0); + let desc = format!("{} {}", FOLDERS, t!("files_location")); + ui.label( + RichText::new(desc).size(15.0).color(Colors::gray()), + ); + ui.add_space(8.0); + }); + }, + ); + }, + ); + }, + ) + .response; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if res.hovered() { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::fill(); + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked { + self.data_path_edit = NodeConfig::get_chain_data_path(); + // Show chain data path edit modal. + Modal::new(DATA_PATH_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network.node")) + .show(); + } + } + + /// Draw data path input [`Modal`] content. + fn data_path_edit_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let on_save = |path: &String| { + Node::change_data_dir(path.clone()); + Modal::close(); + }; + ui.label( + RichText::new(format!("{}:", t!("files_location"))) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw chain data path text edit. + let mut edit = TextEdit::new(Id::from(DATA_PATH_MODAL)).paste(); + edit.ui(ui, &mut self.data_path_edit, cb); + if edit.enter_pressed { + on_save(&self.data_path_edit); + } + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(&self.data_path_edit); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw [`ChainTypes`] setup content. + pub fn chain_type_ui(ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network.type")) + .size(16.0) + .color(Colors::gray()), + ); + }); + + let saved_chain_type = AppConfig::chain_type(); + let mut selected_chain_type = saved_chain_type; + + ui.add_space(8.0); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + let main_type = ChainTypes::Mainnet; + View::radio_value( + ui, + &mut selected_chain_type, + main_type, + t!("network.mainnet"), + ); + }); + columns[1].vertical_centered(|ui| { + let test_type = ChainTypes::Testnet; + View::radio_value( + ui, + &mut selected_chain_type, + test_type, + t!("network.testnet"), + ); + }) + }); + ui.add_space(8.0); + + if saved_chain_type != selected_chain_type && !Node::is_restarting() { + AppConfig::change_chain_type(&selected_chain_type); + if Node::is_running() { + Node::restart(); + } + } + } + + /// Draw API port setup content. + fn api_port_setup_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.api_port")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let (_, port) = NodeConfig::get_api_ip_port(); + View::button( + ui, + format!("{} {}", PLUG, &port), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.api_port_edit = port; + self.api_port_available_edit = self.is_api_port_available; + + // Show API port modal. + Modal::new(API_PORT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + + if !self.is_api_port_available { + // Show error when API server port is unavailable. + ui.label( + RichText::new(t!("network_settings.port_unavailable")) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(6.0); + } + ui.add_space(6.0); + } + + /// Draw API port [`Modal`] content. + fn api_port_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut NodeSetup| { + // Check if port is available. + let (api_ip, _) = NodeConfig::get_api_ip_port(); + let available = NodeConfig::is_api_port_available(&api_ip, &c.api_port_edit); + c.api_port_available_edit = available; + if available { + // Save port at config if it's available. + NodeConfig::save_api_address(&api_ip, &c.api_port_edit); + if Node::is_running() { + Node::restart(); + } + c.is_api_port_available = true; + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.api_port")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + // Draw API port text edit. + let mut api_port_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + api_port_edit.ui(ui, &mut self.api_port_edit, cb); + if api_port_edit.enter_pressed { + on_save(self); + } + + // Show error when specified port is unavailable or reminder to restart enabled node. + if !self.api_port_available_edit { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.port_unavailable")) + .size(16.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw API secret token setup content. + fn secret_ui(&mut self, modal_id: &'static str, ui: &mut egui::Ui) { + let secret_title = match modal_id { + API_SECRET_MODAL => t!("network_settings.api_secret"), + _ => t!("network_settings.foreign_api_secret"), + }; + ui.label(RichText::new(secret_title).size(16.0).color(Colors::gray())); + ui.add_space(6.0); + + let secret_value = match modal_id { + API_SECRET_MODAL => NodeConfig::get_api_secret(false), + _ => NodeConfig::get_api_secret(true), + }; + + let secret_text = if secret_value.is_some() { + format!("{} {}", SHIELD, t!("network_settings.enabled")) + } else { + format!("{} {}", SHIELD_SLASH, t!("network_settings.disabled")) + }; + + View::button(ui, secret_text, Colors::white_or_black(false), || { + // Setup values for modal. + self.secret_edit = secret_value.unwrap_or("".to_string()); + // Show secret edit modal. + Modal::new(modal_id) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }); + } + + /// Draw API secret token [`Modal`] content. + fn secret_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut NodeSetup| { + let secret = c.secret_edit.clone(); + match modal.id { + API_SECRET_MODAL => { + NodeConfig::save_api_secret(&secret); + } + _ => { + NodeConfig::save_foreign_api_secret(&secret); + } + }; + Modal::close(); + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let description = match modal.id { + API_SECRET_MODAL => t!("network_settings.api_secret"), + _ => t!("network_settings.foreign_api_secret"), + }; + ui.label(RichText::new(description).size(17.0).color(Colors::gray())); + ui.add_space(8.0); + + // Draw API secret token value text edit. + let mut secret_edit = TextEdit::new(Id::from(modal.id)).copy().paste(); + secret_edit.ui(ui, &mut self.secret_edit, cb); + if secret_edit.enter_pressed { + on_save(self); + } + + ui.add_space(6.0); + + // Show reminder to restart enabled node. + if Node::is_running() { + ui.label( + RichText::new(t!("network_settings.restart_node_required")) + .size(16.0) + .color(Colors::green()), + ); + ui.add_space(6.0); + } + ui.add_space(4.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw FTL setup content. + fn ftl_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.ftl")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let ftl = NodeConfig::get_ftl(); + View::button( + ui, + format!("{} {}", CLOCK_CLOCKWISE, &ftl), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.ftl_edit = ftl; + // Show ftl value setup modal. + Modal::new(FTL_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("network_settings.ftl_description")) + .size(16.0) + .color(Colors::inactive_text()), + ); + } + + /// Draw FTL [`Modal`] content. + fn ftl_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + // Save button callback. + let on_save = |c: &mut NodeSetup| { + if let Ok(ftl) = c.ftl_edit.parse::() { + NodeConfig::save_ftl(ftl); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.ftl")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw ftl value text edit. + let mut ftl_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + ftl_edit.ui(ui, &mut self.ftl_edit, cb); + if ftl_edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.ftl_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw chain validation mode setup content. + pub fn validation_mode_ui(&mut self, ui: &mut egui::Ui) { + let validate = NodeConfig::is_full_chain_validation(); + View::checkbox(ui, validate, t!("network_settings.full_validation"), || { + NodeConfig::toggle_full_chain_validation(); + }); + ui.add_space(4.0); + ui.label( + RichText::new(t!("network_settings.full_validation_description")) + .size(16.0) + .color(Colors::inactive_text()), + ); + } + + /// Draw archive mode setup content. + fn archive_mode_ui(&mut self, ui: &mut egui::Ui) { + let archive_mode = NodeConfig::is_archive_mode(); + View::checkbox( + ui, + archive_mode, + t!("network_settings.archive_mode"), + || { + NodeConfig::toggle_archive_mode(); + }, + ); + ui.add_space(4.0); + ui.label( + RichText::new(t!("network_settings.archive_mode_desc")) + .size(16.0) + .color(Colors::inactive_text()), + ); + } +} diff --git a/src/gui/views/network/setup/p2p.rs b/src/gui/views/network/setup/p2p.rs new file mode 100644 index 00000000..4dfdbf92 --- /dev/null +++ b/src/gui/views/network/setup/p2p.rs @@ -0,0 +1,884 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::{Align, Id, Layout, RichText, StrokeKind}; +use egui_async::Bind; +use grin_core::global::ChainTypes; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{ + ARROW_FAT_LINES_DOWN, ARROW_FAT_LINES_UP, GLOBE_SIMPLE, HANDSHAKE, PLUG, PLUS_CIRCLE, + PROHIBIT_INSET, TRASH, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::settings::NetworkSettings; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{Modal, TextEdit, View}; +use crate::node::{Node, NodeConfig, PeersConfig}; + +/// Type of peer. +#[derive(Eq, PartialEq)] +enum PeerType { + DefaultSeed, + CustomSeed, + Allowed, + Denied, + Preferred, +} + +/// P2P server setup section content. +pub struct P2PSetup { + /// P2P port value. + port_edit: String, + /// Flag to check if p2p port is available. + port_available_edit: bool, + + /// Flag to check if p2p port from saved config value is available. + is_port_available: bool, + + /// Async check entered peer address. + address_check: Bind, + /// Flag to check if peer is correct and/or available. + address_available: Option, + /// Peer edit value for modal. + peer_edit: String, + + /// Default main network seeds. + default_main_seeds: Vec, + /// Default test network seeds. + default_test_seeds: Vec, + + /// How long banned peer should stay banned. + ban_window_edit: String, + + /// Maximum number of inbound peer connections. + max_inbound_count: String, + + /// Maximum number of outbound peer connections. + max_outbound_count: String, +} + +/// Identifier for port value [`Modal`]. +pub const PORT_MODAL: &'static str = "p2p_port"; +/// Identifier for custom seed [`Modal`]. +pub const CUSTOM_SEED_MODAL: &'static str = "p2p_custom_seed"; +/// Identifier for allowed peer [`Modal`]. +pub const ALLOW_PEER_MODAL: &'static str = "p2p_allow_peer"; +/// Identifier for denied peer [`Modal`]. +pub const DENY_PEER_MODAL: &'static str = "p2p_deny_peer"; +/// Identifier for preferred peer [`Modal`]. +pub const PREFER_PEER_MODAL: &'static str = "p2p_prefer_peer"; +/// Identifier for ban window [`Modal`]. +pub const BAN_WINDOW_MODAL: &'static str = "p2p_ban_window"; +/// Identifier for maximum number of inbound peers [`Modal`]. +pub const MAX_INBOUND_MODAL: &'static str = "p2p_max_inbound"; +/// Identifier for maximum number of outbound peers [`Modal`]. +pub const MAX_OUTBOUND_MODAL: &'static str = "p2p_max_outbound"; + +impl Default for P2PSetup { + fn default() -> Self { + let port = NodeConfig::get_p2p_port(); + let is_port_available = NodeConfig::is_p2p_port_available(&port); + let default_main_seeds = Node::MAINNET_DNS_SEEDS + .iter() + .map(|s| s.to_string()) + .collect(); + let default_test_seeds = Node::TESTNET_DNS_SEEDS + .into_iter() + .map(|s| s.to_string()) + .collect(); + Self { + port_edit: port, + port_available_edit: is_port_available, + address_check: Bind::new(false), + address_available: Some(true), + is_port_available, + peer_edit: "".to_string(), + default_main_seeds, + default_test_seeds, + ban_window_edit: NodeConfig::get_p2p_ban_window(), + max_inbound_count: NodeConfig::get_max_inbound_peers(), + max_outbound_count: NodeConfig::get_max_outbound_peers(), + } + } +} + +impl ContentContainer for P2PSetup { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + PORT_MODAL, + CUSTOM_SEED_MODAL, + ALLOW_PEER_MODAL, + DENY_PEER_MODAL, + PREFER_PEER_MODAL, + BAN_WINDOW_MODAL, + MAX_INBOUND_MODAL, + MAX_OUTBOUND_MODAL, + ] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + PORT_MODAL => self.port_modal(ui, modal, cb), + CUSTOM_SEED_MODAL => self.peer_modal(ui, modal, cb), + ALLOW_PEER_MODAL => self.peer_modal(ui, modal, cb), + DENY_PEER_MODAL => self.peer_modal(ui, modal, cb), + PREFER_PEER_MODAL => self.peer_modal(ui, modal, cb), + BAN_WINDOW_MODAL => self.ban_window_modal(ui, modal, cb), + MAX_INBOUND_MODAL => self.max_inbound_modal(ui, modal, cb), + MAX_OUTBOUND_MODAL => self.max_outbound_modal(ui, modal, cb), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + View::sub_title( + ui, + format!("{} {}", HANDSHAKE, t!("network_settings.p2p_server")), + ); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + ui.vertical_centered(|ui| { + // Show p2p port setup. + self.port_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show seeding type setup. + self.seeding_type_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + ui.label( + RichText::new(t!("network_settings.allow_list")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + // Show allowed peers setup. + self.peer_list_ui(ui, &PeerType::Allowed); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + ui.label( + RichText::new(t!("network_settings.deny_list")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + // Show denied peers setup. + self.peer_list_ui(ui, &PeerType::Denied); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + ui.label( + RichText::new(t!("network_settings.favourites")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + // Show preferred peers setup. + self.peer_list_ui(ui, &PeerType::Preferred); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show ban window setup. + self.ban_window_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show maximum inbound peers value setup. + self.max_inbound_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show maximum outbound peers value setup. + self.max_outbound_ui(ui); + }); + } +} + +/// Title for custom DNS Seeds setup section. +const DNS_SEEDS_TITLE: &'static str = "DNS Seeds"; + +impl P2PSetup { + /// Draw p2p port setup content. + fn port_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.p2p_port")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let port = NodeConfig::get_p2p_port(); + View::button( + ui, + format!("{} {}", PLUG, &port), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.port_edit = port; + self.port_available_edit = self.is_port_available; + // Show p2p port modal. + Modal::new(PORT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + + // Show error when p2p port is unavailable. + if !self.is_port_available { + ui.add_space(6.0); + ui.label( + RichText::new(t!("network_settings.port_unavailable")) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(12.0); + } + } + + /// Draw p2p port [`Modal`] content. + fn port_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut P2PSetup| { + // Check if port is available. + let available = NodeConfig::is_p2p_port_available(&c.port_edit); + c.port_available_edit = available; + + // Save port at config if it's available. + if available { + NodeConfig::save_p2p_port(c.port_edit.parse::().unwrap()); + if Node::is_running() { + Node::restart(); + } + c.is_port_available = true; + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.p2p_port")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw p2p port text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.port_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified port is unavailable. + if !self.port_available_edit { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.port_unavailable")) + .size(17.0) + .color(Colors::red()), + ); + } + + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw peer list content based on provided [`PeerType`]. + fn peer_list_ui(&mut self, ui: &mut egui::Ui, peer_type: &PeerType) { + let peers = match peer_type { + PeerType::DefaultSeed => { + if AppConfig::chain_type() == ChainTypes::Testnet { + self.default_test_seeds.clone() + } else { + self.default_main_seeds.clone() + } + } + PeerType::CustomSeed => NodeConfig::get_custom_seeds(), + PeerType::Allowed => NodeConfig::get_allowed_peers(), + PeerType::Denied => NodeConfig::get_denied_peers(), + PeerType::Preferred => NodeConfig::get_preferred_peers(), + }; + for (index, peer) in peers.iter().enumerate() { + ui.horizontal_wrapped(|ui| { + // Draw peer list item. + peer_item_ui(ui, peer, peer_type, index, peers.len()); + }); + } + + if peer_type != &PeerType::DefaultSeed { + // Draw description. + if peer_type != &PeerType::CustomSeed { + if !peers.is_empty() { + ui.add_space(12.0); + } + let desc = match peer_type { + PeerType::Allowed => t!("network_settings.allow_list_desc"), + PeerType::Denied => t!("network_settings.deny_list_desc"), + &_ => t!("network_settings.favourites_desc"), + }; + ui.label( + RichText::new(desc) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(12.0); + } else if !peers.is_empty() { + ui.add_space(12.0); + } + + let add_text = if peer_type == &PeerType::CustomSeed { + format!("{} {}", PLUS_CIRCLE, t!("network_settings.add_seed")) + } else { + format!("{} {}", PLUS_CIRCLE, t!("network_settings.add_peer")) + }; + View::button(ui, add_text, Colors::white_or_black(false), || { + // Setup values for modal. + self.address_check = Bind::new(false); + self.address_available = Some(true); + self.peer_edit = "".to_string(); + // Select modal id. + let modal_id = match peer_type { + PeerType::Allowed => ALLOW_PEER_MODAL, + PeerType::Denied => DENY_PEER_MODAL, + PeerType::Preferred => PREFER_PEER_MODAL, + _ => CUSTOM_SEED_MODAL, + }; + // Select modal title. + let modal_title = match peer_type { + PeerType::Allowed => t!("network_settings.allow_list").into(), + PeerType::Denied => t!("network_settings.deny_list").into(), + PeerType::Preferred => t!("network_settings.favourites").into(), + _ => DNS_SEEDS_TITLE.to_string(), + }; + // Show modal to add peer. + Modal::new(modal_id) + .position(ModalPosition::CenterTop) + .title(modal_title) + .show(); + }); + } + ui.add_space(6.0); + } + + /// Draw peer creation [`Modal`] content. + fn peer_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + if self.address_available.is_none() { + let peer = self.peer_edit.clone(); + if let Some(res) = self.address_check.read_or_request(|| async { + let available = PeersConfig::peer_to_addr(peer).is_some(); + Ok(available) + }) { + match res { + Ok(available) => { + self.address_available = Some(*available); + // Save peer at config. + if *available { + let peer = self.peer_edit.clone(); + match modal.id { + CUSTOM_SEED_MODAL => NodeConfig::save_custom_seed(peer), + ALLOW_PEER_MODAL => NodeConfig::allow_peer(peer), + DENY_PEER_MODAL => NodeConfig::deny_peer(peer), + PREFER_PEER_MODAL => NodeConfig::prefer_peer(peer), + &_ => {} + } + Modal::close(); + } + } + Err(_) => { + self.address_available = Some(false); + } + } + } + } + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let label_text = match modal.id { + CUSTOM_SEED_MODAL => t!("network_settings.seed_address"), + &_ => t!("network_settings.peer_address"), + }; + ui.label(RichText::new(label_text).size(17.0).color(Colors::gray())); + ui.add_space(8.0); + + // Draw peer address text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).paste(); + if self.address_available.is_none() { + edit = edit.disable(); + } + edit.ui(ui, &mut self.peer_edit, cb); + if edit.enter_pressed { + self.address_available = None; + } + + // Show error when specified address is incorrect. + if let Some(available) = self.address_available { + if !available { + ui.add_space(10.0); + ui.label( + RichText::new(t!("network_settings.peer_address_error")) + .size(16.0) + .color(Colors::red()), + ); + } + } + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + if self.address_available.is_some() { + self.address_available = None; + } + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw seeding type setup content. + fn seeding_type_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(DNS_SEEDS_TITLE) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(2.0); + + let default_seeding = NodeConfig::is_default_seeding_type(); + View::checkbox(ui, default_seeding, t!("network_settings.default"), || { + NodeConfig::toggle_seeding_type(); + }); + ui.add_space(8.0); + + let peers_type = if default_seeding { + PeerType::DefaultSeed + } else { + PeerType::CustomSeed + }; + self.peer_list_ui(ui, &peers_type); + } + + /// Draw ban window setup content. + fn ban_window_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.ban_window")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let ban_window = NodeConfig::get_p2p_ban_window(); + View::button( + ui, + format!("{} {}", PROHIBIT_INSET, &ban_window), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.ban_window_edit = ban_window; + // Show ban window period setup modal. + Modal::new(BAN_WINDOW_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("network_settings.ban_window_desc")) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(2.0); + } + + /// Draw ban window [`Modal`] content. + fn ban_window_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut P2PSetup| { + if let Ok(ban_window) = c.ban_window_edit.parse::() { + NodeConfig::save_p2p_ban_window(ban_window); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.ban_window")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw ban window text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.ban_window_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.ban_window_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw maximum number of inbound peers setup content. + fn max_inbound_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.max_inbound_count")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let max_inbound = NodeConfig::get_max_inbound_peers(); + View::button( + ui, + format!("{} {}", ARROW_FAT_LINES_DOWN, &max_inbound), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.max_inbound_count = max_inbound; + // Show maximum number of inbound peers setup modal. + Modal::new(MAX_INBOUND_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw maximum number of inbound peers [`Modal`] content. + fn max_inbound_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut P2PSetup| { + if let Ok(max_inbound) = c.max_inbound_count.parse::() { + NodeConfig::save_max_inbound_peers(max_inbound); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.max_inbound_count")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw maximum number of inbound peers text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.max_inbound_count, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.max_inbound_count.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw maximum number of outbound peers setup content. + fn max_outbound_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.max_outbound_count")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + let max_outbound = NodeConfig::get_max_outbound_peers(); + View::button( + ui, + format!("{} {}", ARROW_FAT_LINES_UP, &max_outbound), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.max_outbound_count = max_outbound; + // Show maximum number of outbound peers setup modal. + Modal::new(MAX_OUTBOUND_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw maximum number of outbound peers [`Modal`] content. + fn max_outbound_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut P2PSetup| { + if let Ok(max_outbound) = c.max_outbound_count.parse::() { + NodeConfig::save_max_outbound_peers(max_outbound); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.max_outbound_count")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw maximum number of outbound peers text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.max_outbound_count, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.max_outbound_count.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } +} + +/// Draw peer list item. +fn peer_item_ui( + ui: &mut egui::Ui, + peer_addr: &String, + peer_type: &PeerType, + index: usize, + len: usize, +) { + // Setup layout size. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(42.0); + + // Draw round background. + let item_rounding = View::item_rounding(index, len, false); + ui.painter().rect( + rect, + item_rounding, + Colors::fill(), + View::item_stroke(), + StrokeKind::Outside, + ); + + ui.vertical(|ui| { + ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| { + // Draw delete button for non-default seed peers. + if peer_type != &PeerType::DefaultSeed { + let r = View::item_rounding(index, len, true); + View::item_button( + ui, + r, + TRASH, + Some(Colors::inactive_text()), + || match peer_type { + PeerType::CustomSeed => { + NodeConfig::remove_custom_seed(peer_addr); + } + PeerType::Allowed => { + NodeConfig::remove_allowed_peer(peer_addr); + } + PeerType::Denied => { + NodeConfig::remove_denied_peer(peer_addr); + } + PeerType::Preferred => { + NodeConfig::remove_preferred_peer(peer_addr); + } + PeerType::DefaultSeed => {} + }, + ); + } + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| { + ui.add_space(6.0); + // Draw peer address. + let peer_text = format!("{} {}", GLOBE_SIMPLE, &peer_addr); + ui.label( + RichText::new(peer_text) + .color(Colors::text_button()) + .size(16.0), + ); + }); + }); + }); +} diff --git a/src/gui/views/network/setup/pool.rs b/src/gui/views/network/setup/pool.rs new file mode 100644 index 00000000..f44b88f2 --- /dev/null +++ b/src/gui/views/network/setup/pool.rs @@ -0,0 +1,593 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; + +use crate::gui::Colors; +use crate::gui::icons::{ + BEZIER_CURVE, BOUNDING_BOX, CHART_SCATTER, CIRCLES_THREE, CLOCK_COUNTDOWN, HAND_COINS, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::settings::NetworkSettings; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{Modal, TextEdit, View}; +use crate::node::NodeConfig; + +/// Memory pool setup section content. +pub struct PoolSetup { + /// Base fee value that's accepted into the pool. + fee_base_edit: String, + /// Reorg cache retention period value in minutes. + reorg_period_edit: String, + /// Maximum number of transactions allowed in the pool. + pool_size_edit: String, + /// Maximum number of transactions allowed in the stempool. + stempool_size_edit: String, + /// Maximum total weight of transactions to build a block. + max_weight_edit: String, +} + +/// Identifier for base fee value [`Modal`]. +const FEE_BASE_MODAL: &'static str = "fee_base"; +/// Identifier for reorg cache retention period value [`Modal`]. +const REORG_PERIOD_MODAL: &'static str = "reorg_period"; +/// Identifier for maximum number of transactions in the pool [`Modal`]. +const POOL_SIZE_MODAL: &'static str = "pool_size"; +/// Identifier for maximum number of transactions in the stempool [`Modal`]. +const STEMPOOL_SIZE_MODAL: &'static str = "stempool_size"; +/// Identifier for maximum total weight of transactions [`Modal`]. +const MAX_WEIGHT_MODAL: &'static str = "max_weight"; + +impl Default for PoolSetup { + fn default() -> Self { + Self { + fee_base_edit: NodeConfig::get_base_fee(), + reorg_period_edit: NodeConfig::get_reorg_cache_period(), + pool_size_edit: NodeConfig::get_max_pool_size(), + stempool_size_edit: NodeConfig::get_max_stempool_size(), + max_weight_edit: NodeConfig::get_mineable_max_weight(), + } + } +} + +impl ContentContainer for PoolSetup { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + FEE_BASE_MODAL, + REORG_PERIOD_MODAL, + POOL_SIZE_MODAL, + STEMPOOL_SIZE_MODAL, + MAX_WEIGHT_MODAL, + ] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + FEE_BASE_MODAL => self.fee_base_modal(ui, modal, cb), + REORG_PERIOD_MODAL => self.reorg_period_modal(ui, modal, cb), + POOL_SIZE_MODAL => self.pool_size_modal(ui, modal, cb), + STEMPOOL_SIZE_MODAL => self.stem_size_modal(ui, modal, cb), + MAX_WEIGHT_MODAL => self.max_weight_modal(ui, modal, cb), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + View::sub_title( + ui, + format!("{} {}", CHART_SCATTER, t!("network_settings.tx_pool")), + ); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + ui.vertical_centered(|ui| { + // Show base fee setup. + self.fee_base_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show reorg cache retention period setup. + self.reorg_period_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show pool size setup. + self.pool_size_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show stem pool size setup. + self.stem_size_ui(ui); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show max weight of transactions setup. + self.max_weight_ui(ui); + }); + } +} + +impl PoolSetup { + /// Draw fee base setup content. + fn fee_base_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.pool_fee")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let fee = NodeConfig::get_base_fee(); + View::button( + ui, + format!("{} {}", HAND_COINS, &fee), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.fee_base_edit = fee; + // Show fee setup modal. + Modal::new(FEE_BASE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw fee base [`Modal`] content. + fn fee_base_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut PoolSetup| { + if let Ok(fee) = c.fee_base_edit.parse::() { + NodeConfig::save_base_fee(fee); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.pool_fee")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw fee base text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.fee_base_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.fee_base_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw reorg cache retention period setup content. + fn reorg_period_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.reorg_period")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + let period = NodeConfig::get_reorg_cache_period(); + View::button( + ui, + format!("{} {}", CLOCK_COUNTDOWN, &period), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.reorg_period_edit = period; + // Show reorg period setup modal. + Modal::new(REORG_PERIOD_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw reorg cache retention period [`Modal`] content. + fn reorg_period_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut PoolSetup| { + if let Ok(period) = c.reorg_period_edit.parse::() { + NodeConfig::save_reorg_cache_period(period); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.reorg_period")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw reorg period text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.reorg_period_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.reorg_period_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw maximum number of transactions in the pool setup content. + fn pool_size_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.max_tx_pool")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let size = NodeConfig::get_max_pool_size(); + View::button( + ui, + format!("{} {}", CIRCLES_THREE, size), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.pool_size_edit = size; + // Show pool size setup modal. + Modal::new(POOL_SIZE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw maximum number of transactions in the pool [`Modal`] content. + fn pool_size_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut PoolSetup| { + if let Ok(size) = c.pool_size_edit.parse::() { + NodeConfig::save_max_pool_size(size); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.max_tx_pool")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw pool size text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.pool_size_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.pool_size_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw maximum number of transactions in the stempool setup content. + fn stem_size_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.max_tx_stempool")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let size = NodeConfig::get_max_stempool_size(); + View::button( + ui, + format!("{} {}", BEZIER_CURVE, &size), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.stempool_size_edit = size; + // Show stempool size setup modal. + Modal::new(STEMPOOL_SIZE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw maximum number of transactions in the stempool [`Modal`] content. + fn stem_size_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut PoolSetup| { + if let Ok(size) = c.stempool_size_edit.parse::() { + NodeConfig::save_max_stempool_size(size); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.max_tx_stempool")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw stempool size text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.stempool_size_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.stempool_size_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw maximum total weight of transactions setup content. + fn max_weight_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.max_tx_weight")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let weight = NodeConfig::get_mineable_max_weight(); + View::button( + ui, + format!("{} {}", BOUNDING_BOX, &weight), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.max_weight_edit = weight; + // Show total tx weight setup modal. + Modal::new(MAX_WEIGHT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw maximum total weight of transactions [`Modal`] content. + fn max_weight_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut PoolSetup| { + if let Ok(weight) = c.max_weight_edit.parse::() { + NodeConfig::save_mineable_max_weight(weight); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.max_tx_weight")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw tx weight text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.max_weight_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.max_weight_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + NetworkSettings::node_restart_required_ui(ui); + } + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } +} diff --git a/src/gui/views/network/setup/stratum.rs b/src/gui/views/network/setup/stratum.rs new file mode 100644 index 00000000..4933ef92 --- /dev/null +++ b/src/gui/views/network/setup/stratum.rs @@ -0,0 +1,576 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; +use grin_chain::SyncStatus; + +use crate::gui::Colors; +use crate::gui::icons::{BARBELL, HARD_DRIVES, PLUG, POWER, TIMER}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::settings::NetworkSettings; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::wallets::modals::WalletListModal; +use crate::gui::views::{Modal, TextEdit, View}; +use crate::node::{Node, NodeConfig}; +use crate::wallet::{WalletConfig, WalletList}; + +/// Stratum server setup section content. +pub struct StratumSetup { + /// Wallet list to select for mining rewards. + wallets: WalletList, + /// Wallets [`Modal`] content. + wallets_modal: WalletListModal, + + /// IP Addresses available at system. + available_ips: Vec, + + /// Stratum port value. + stratum_port_edit: String, + /// Flag to check if stratum port is available. + stratum_port_available_edit: bool, + + /// Flag to check if stratum port from saved config value is available. + is_port_available: bool, + + /// Wallet name to receive rewards. + pub wallet_name: Option, + + /// Attempt time value in seconds to mine on a particular header. + attempt_time_edit: String, + + /// Minimum share difficulty value to request from miners. + min_share_diff_edit: String, +} + +/// Identifier for wallet selection [`Modal`]. +const WALLET_SELECTION_MODAL: &'static str = "stratum_wallet_selection_modal"; +/// Identifier for stratum port [`Modal`]. +const STRATUM_PORT_MODAL: &'static str = "stratum_port"; +/// Identifier for attempt time [`Modal`]. +const ATTEMPT_TIME_MODAL: &'static str = "stratum_attempt_time"; +/// Identifier for minimum share difficulty [`Modal`]. +const MIN_SHARE_DIFF_MODAL: &'static str = "stratum_min_share_diff"; + +impl Default for StratumSetup { + fn default() -> Self { + let (ip, port) = NodeConfig::get_stratum_address(); + let is_port_available = NodeConfig::is_stratum_port_available(&ip, &port); + + // Setup mining rewards wallet name and identifier. + let mut wallet_id = NodeConfig::get_stratum_wallet_id(); + let wallet_name = if let Some(id) = wallet_id { + WalletConfig::read_name_by_id(id) + } else { + None + }; + if wallet_name.is_none() { + wallet_id = None; + } + + Self { + wallets: WalletList::default(), + wallets_modal: WalletListModal::new(wallet_id, None, false), + available_ips: NodeConfig::get_ip_addrs(), + stratum_port_edit: port, + stratum_port_available_edit: is_port_available, + is_port_available, + wallet_name, + attempt_time_edit: NodeConfig::get_stratum_attempt_time(), + min_share_diff_edit: NodeConfig::get_stratum_min_share_diff(), + } + } +} + +impl ContentContainer for StratumSetup { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + WALLET_SELECTION_MODAL, + STRATUM_PORT_MODAL, + ATTEMPT_TIME_MODAL, + MIN_SHARE_DIFF_MODAL, + ] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + WALLET_SELECTION_MODAL => self.wallets_modal.ui(ui, &mut self.wallets, |wallet, _| { + let id = wallet.get_config().id; + NodeConfig::save_stratum_wallet_id(id); + self.wallet_name = WalletConfig::read_name_by_id(id); + }), + STRATUM_PORT_MODAL => self.port_modal(ui, modal, cb), + ATTEMPT_TIME_MODAL => self.attempt_modal(ui, modal, cb), + MIN_SHARE_DIFF_MODAL => self.min_diff_modal(ui, modal, cb), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + View::sub_title( + ui, + format!("{} {}", HARD_DRIVES, t!("network_mining.server")), + ); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + ui.vertical_centered(|ui| { + // Show loading indicator or controls to start/stop stratum server. + if Node::get_sync_status().unwrap_or(SyncStatus::Initial) == SyncStatus::NoSync + && self.is_port_available + && self.wallet_name.is_some() + { + if Node::is_stratum_starting() || Node::is_stratum_stopping() { + ui.vertical_centered(|ui| { + ui.add_space(8.0); + View::small_loading_spinner(ui); + ui.add_space(8.0); + }); + } else if Node::get_stratum_stats().is_running { + ui.add_space(6.0); + let disable_text = format!("{} {}", POWER, t!("network_settings.disable")); + View::action_button(ui, disable_text, || { + Node::stop_stratum(); + let (ip, port) = NodeConfig::get_stratum_address(); + self.is_port_available = NodeConfig::is_stratum_port_available(&ip, &port); + }); + ui.add_space(6.0); + } else { + ui.add_space(6.0); + let enable_text = format!("{} {}", POWER, t!("network_settings.enable")); + View::action_button(ui, enable_text, || { + Node::start_stratum(); + }); + ui.add_space(6.0); + } + } + + // Show stratum server autorun checkbox. + let stratum_enabled = NodeConfig::is_stratum_autorun_enabled(); + View::checkbox(ui, stratum_enabled, t!("network.autorun"), || { + NodeConfig::toggle_stratum_autorun(); + }); + + // Show reminder to restart running server. + if Node::get_stratum_stats().is_running { + ui.add_space(2.0); + ui.label( + RichText::new(t!("network_mining.restart_server_required")) + .size(16.0) + .color(Colors::inactive_text()), + ); + } + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Show wallet name. + ui.label( + RichText::new(self.wallet_name.as_ref().unwrap_or(&"-".to_string())) + .size(16.0) + .color(Colors::white_or_black(true)), + ); + ui.add_space(8.0); + + // Show button to select wallet. + View::button( + ui, + t!("network_settings.choose_wallet"), + Colors::white_or_black(false), + || { + self.show_wallets_modal(); + }, + ); + ui.add_space(12.0); + + if self.wallet_name.is_some() { + ui.label( + RichText::new(t!("network_settings.stratum_wallet_warning")) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(12.0); + } + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + }); + + // Show message when IP addresses are not available on the system. + if self.available_ips.is_empty() { + NetworkSettings::no_ip_address_ui(ui); + return; + } + + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.stratum_ip")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + // Show stratum IP addresses to select. + let (ip, port) = NodeConfig::get_stratum_address(); + NetworkSettings::ip_addrs_ui(ui, &ip, &self.available_ips, |selected_ip| { + NodeConfig::save_stratum_address(selected_ip, &port); + self.is_port_available = NodeConfig::is_stratum_port_available(selected_ip, &port); + }); + // Show stratum port setup. + self.port_setup_ui(ui); + + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show attempt time setup. + self.attempt_time_ui(ui); + + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show minimum acceptable share difficulty setup. + self.min_diff_ui(ui); + }); + } +} + +impl StratumSetup { + /// Show wallet selection [`Modal`]. + fn show_wallets_modal(&mut self) { + self.wallets_modal = WalletListModal::new(NodeConfig::get_stratum_wallet_id(), None, false); + // Show modal. + Modal::new(WALLET_SELECTION_MODAL) + .position(ModalPosition::Center) + .title(t!("network_settings.choose_wallet")) + .show(); + } + + /// Draw stratum port value setup content. + fn port_setup_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.stratum_port")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let (_, port) = NodeConfig::get_stratum_address(); + View::button( + ui, + format!("{} {}", PLUG, &port), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.stratum_port_edit = port; + self.stratum_port_available_edit = self.is_port_available; + // Show stratum port modal. + Modal::new(STRATUM_PORT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(12.0); + + // Show error when stratum server port is unavailable. + if !self.is_port_available { + ui.add_space(6.0); + ui.label( + RichText::new(t!("network_settings.port_unavailable")) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(12.0); + } + } + + /// Draw stratum port [`Modal`] content. + fn port_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut StratumSetup| { + // Check if port is available. + let (stratum_ip, _) = NodeConfig::get_stratum_address(); + let available = + NodeConfig::is_stratum_port_available(&stratum_ip, &c.stratum_port_edit); + c.stratum_port_available_edit = available; + + // Save port at config if it's available. + if available { + NodeConfig::save_stratum_address(&stratum_ip, &c.stratum_port_edit); + + c.is_port_available = true; + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.stratum_port")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw stratum port text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.stratum_port_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified port is unavailable. + if !self.stratum_port_available_edit { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.port_unavailable")) + .size(17.0) + .color(Colors::red()), + ); + } else { + server_restart_required_ui(ui); + } + + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw attempt time value setup content. + fn attempt_time_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.attempt_time")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let time = NodeConfig::get_stratum_attempt_time(); + View::button( + ui, + format!("{} {}", TIMER, &time), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.attempt_time_edit = time; + + // Show attempt time modal. + Modal::new(ATTEMPT_TIME_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("network_settings.attempt_time_desc")) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(6.0); + } + + /// Draw attempt time [`Modal`] content. + fn attempt_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut StratumSetup| { + if let Ok(time) = c.attempt_time_edit.parse::() { + NodeConfig::save_stratum_attempt_time(time); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.attempt_time")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw attempt time text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.attempt_time_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.attempt_time_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + server_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw minimum share difficulty value setup content. + fn min_diff_ui(&mut self, ui: &mut egui::Ui) { + ui.label( + RichText::new(t!("network_settings.min_share_diff")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + let diff = NodeConfig::get_stratum_min_share_diff(); + View::button( + ui, + format!("{} {}", BARBELL, &diff), + Colors::white_or_black(false), + || { + // Setup values for modal. + self.min_share_diff_edit = diff; + + // Show share difficulty setup modal. + Modal::new(MIN_SHARE_DIFF_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }, + ); + ui.add_space(6.0); + } + + /// Draw minimum acceptable share difficulty [`Modal`] content. + fn min_diff_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut StratumSetup| { + if let Ok(diff) = c.min_share_diff_edit.parse::() { + NodeConfig::save_stratum_min_share_diff(diff); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("network_settings.min_share_diff")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw share difficulty text edit. + let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + edit.ui(ui, &mut self.min_share_diff_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.min_share_diff_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } else { + server_restart_required_ui(ui); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } +} + +/// Reminder to restart enabled node to show on edit setting at [`Modal`]. +pub fn server_restart_required_ui(ui: &mut egui::Ui) { + if Node::get_stratum_stats().is_running { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_mining.restart_server_required")) + .size(16.0) + .color(Colors::green()), + ); + } +} diff --git a/src/gui/views/network/types.rs b/src/gui/views/network/types.rs new file mode 100644 index 00000000..d045a530 --- /dev/null +++ b/src/gui/views/network/types.rs @@ -0,0 +1,51 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use crate::gui::platform::PlatformCallbacks; +use serde_derive::{Deserialize, Serialize}; + +/// Integrated node tab content interface. +pub trait NodeTab { + fn get_type(&self) -> NodeTabType; + fn tab_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks); +} + +/// Type of [`NodeTab`] content. +#[derive(PartialEq)] +pub enum NodeTabType { + Info, + Metrics, + Mining, + Settings, +} + +impl NodeTabType { + pub fn title(&self) -> String { + match *self { + NodeTabType::Info => t!("network.node").into(), + NodeTabType::Metrics => t!("network.metrics").into(), + NodeTabType::Mining => t!("network.mining").into(), + NodeTabType::Settings => t!("network.settings").into(), + } + } +} + +/// Connection details to share. +#[derive(Serialize, Deserialize, Clone)] +pub struct ShareConnection { + #[serde(rename(serialize = "ipPort", deserialize = "ipPort"))] + pub url: String, + pub username: String, + pub secret: String, +} diff --git a/src/gui/views/pull_to_refresh.rs b/src/gui/views/pull_to_refresh.rs new file mode 100644 index 00000000..53e8544b --- /dev/null +++ b/src/gui/views/pull_to_refresh.rs @@ -0,0 +1,381 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::epaint::{Pos2, Shape, Stroke, emath::lerp, vec2}; +use egui::scroll_area::ScrollAreaOutput; +use egui::{Align2, Area, Color32, Id, Rect, Response, Sense, UiBuilder, Vec2, Widget}; + +/// A spinner widget used to indicate loading. +/// This was taken from egui and modified slightly to allow passing a progress value +#[must_use = "You should put this widget in an ui with `ui.add(widget);`"] +#[derive(Default)] +pub struct ProgressSpinner { + /// Uses the style's `interact_size` if `None`. + size: Option, + color: Option, + progress: Option, +} + +impl ProgressSpinner { + /// Create a new spinner that uses the style's `interact_size` unless changed. + pub fn new() -> Self { + Self::default() + } + + /// Sets the spinner's size. The size sets both the height and width, as the spinner is always + /// square. If the size isn't set explicitly, the active style's `interact_size` is used. + #[allow(unused)] + pub fn size(mut self, size: f32) -> Self { + self.size = Some(size); + self + } + + /// Sets the spinner's color. + pub fn color(mut self, color: impl Into) -> Self { + self.color = Some(color.into()); + self + } + + /// Sets the spinner's progress. + /// Should be in the range `[0.0, 1.0]`. + pub fn progress(mut self, progress: impl Into>) -> Self { + self.progress = progress.into(); + self + } + + /// Paint the spinner in the given rectangle. + pub fn paint_at(&self, ui: &egui::Ui, rect: Rect) { + if ui.is_rect_visible(rect) { + ui.ctx().request_repaint(); // because it is animated + + let color = self + .color + .unwrap_or_else(|| ui.visuals().strong_text_color()); + let radius = (rect.height() / 2.0) - 2.0; + let n_points = 20; + + let (start_angle, end_angle) = if let Some(progress) = self.progress { + let start_angle = 0f64.to_radians(); + let end_angle = start_angle + 360f64.to_radians() * progress; + (start_angle, end_angle) + } else { + let time = ui.input(|i| i.time); + let start_angle = time * std::f64::consts::TAU; + let end_angle = start_angle + 240f64.to_radians() * time.sin(); + (start_angle, end_angle) + }; + + let points: Vec = (0..=n_points) + .map(|i| { + let angle = lerp(start_angle..=end_angle, i as f64 / n_points as f64); + let (sin, cos) = angle.sin_cos(); + rect.center() + radius * vec2(cos as f32, sin as f32) + }) + .collect(); + ui.painter() + .add(Shape::line(points, Stroke::new(3.0, color))); + } + } +} + +impl Widget for ProgressSpinner { + fn ui(self, ui: &mut egui::Ui) -> Response { + let size = self + .size + .unwrap_or_else(|| ui.style().spacing.interact_size.y); + let (rect, response) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + self.paint_at(ui, rect); + + response + } +} + +/// The current state of the pull to refresh widget. +#[derive(Debug, Clone)] +pub enum PullToRefreshState { + /// The widget is idle, no refresh is happening. + Idle, + /// The user is dragging. + Dragging { + /// `distance` is the distance the user dragged. + distance: f32, + /// `far_enough` is true if the user dragged far enough to trigger a refresh. + far_enough: bool, + }, + /// The user dragged far enough to trigger a refresh. + DoRefresh, + /// The refresh is currently happening. + Refreshing, +} + +impl PullToRefreshState { + fn progress(&self, min_distance: f32) -> Option { + match self { + PullToRefreshState::Idle => Some(0.0), + PullToRefreshState::Dragging { distance, .. } => { + Some((distance / min_distance).min(1.0).max(0.0) as f64) + } + PullToRefreshState::DoRefresh => Some(1.0), + PullToRefreshState::Refreshing => None, + } + } +} + +/// The response of the pull to refresh widget. +#[derive(Debug, Clone)] +pub struct PullToRefreshResponse { + /// Current state of the pull to refresh widget. + pub state: PullToRefreshState, + /// The inner response of the widget you wrapped in [`PullToRefresh::ui`] or [`PullToRefresh::scroll_area_ui`]. + pub inner: T, +} + +impl PullToRefreshResponse { + /// Returns true if the user dragged far enough to trigger a refresh. + pub fn should_refresh(&self) -> bool { + matches!(self.state, PullToRefreshState::DoRefresh) + } +} + +/// A widget that allows the user to pull to refresh. +pub struct PullToRefresh { + id: Id, + loading: bool, + min_refresh_distance: f32, + can_refresh: bool, +} + +impl PullToRefresh { + /// Creates a new pull to refresh widget. + /// If `loading` is true, the widget will show the loading indicator. + pub fn new(loading: bool) -> Self { + Self { + id: Id::new("pull_to_refresh"), + loading, + min_refresh_distance: 100.0, + can_refresh: true, + } + } + + /// Sets the minimum distance the user needs to drag to trigger a refresh. + pub fn min_refresh_distance(mut self, min_refresh_distance: f32) -> Self { + self.min_refresh_distance = min_refresh_distance; + self + } + + /// You need to provide a id if you use multiple pull to refresh widgets at once. + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } + + /// If `can_refresh` is false, pulling will not trigger a refresh. + pub fn can_refresh(mut self, can_refresh: bool) -> Self { + self.can_refresh = can_refresh; + self + } + + /// Shows the pull to refresh widget. + /// Note: If you want to use the pull to refresh widget in a scroll area, use [`Self::scroll_area_ui`]. + /// You might want to disable text selection via [`egui::style::Interaction`] + /// to avoid conflicts with the drag gesture. + pub fn ui( + self, + ui: &mut egui::Ui, + content: impl FnOnce(&mut egui::Ui) -> T, + ) -> PullToRefreshResponse { + let mut child = ui.new_child( + UiBuilder::new() + .max_rect(ui.available_rect_before_wrap()) + .layout(*ui.layout()), + ); + + let output = content(&mut child); + + let can_refresh = self.can_refresh; + let state = self.internal_ui(ui, can_refresh, None, child.min_rect()); + + PullToRefreshResponse { + state, + inner: output, + } + } + + /// Shows the pull to refresh widget, wrapping a [egui::ScrollArea]. + /// Pass the output of the scroll area to the content function. + pub fn scroll_area_ui( + self, + ui: &mut egui::Ui, + content: impl FnOnce(&mut egui::Ui) -> ScrollAreaOutput, + ) -> PullToRefreshResponse> { + let scroll_output = content(ui); + let content_rect = scroll_output.inner_rect; + let can_refresh = scroll_output.state.offset.y == 0.0 && self.can_refresh; + // This is the id used in the Sense of the scroll area + // I hope this id is stable across egui patches... + let allow_dragged_id = scroll_output.id.with("area"); + let state = self.internal_ui(ui, can_refresh, Some(allow_dragged_id), content_rect); + PullToRefreshResponse { + state, + inner: scroll_output, + } + } + + fn internal_ui( + self, + ui: &mut egui::Ui, + can_refresh: bool, + allow_dragged_id: Option, + content_rect: Rect, + ) -> PullToRefreshState { + let last_state = ui.data_mut(|data| { + data.get_temp_mut_or(self.id, PullToRefreshState::Idle) + .clone() + }); + + let mut state = last_state; + if self.loading { + state = PullToRefreshState::Refreshing; + } + + if !self.loading && matches!(state, PullToRefreshState::Refreshing) { + state = PullToRefreshState::Idle; + } + + if can_refresh && !self.loading { + let sense = ui.interact(content_rect, self.id, Sense::hover()); + + let is_something_blocking_drag = ui.ctx().dragged_id().is_some() + && !allow_dragged_id.map_or(false, |id| ui.ctx().is_being_dragged(id)); + + if sense.contains_pointer() && !is_something_blocking_drag { + let (delta, any_released) = ui.input(|input| { + ( + if input.pointer.is_decidedly_dragging() { + Some(input.pointer.delta()) + } else { + None + }, + input.pointer.any_released(), + ) + }); + if let Some(delta) = delta { + if matches!(state, PullToRefreshState::Idle) { + state = PullToRefreshState::Dragging { + distance: 0.0, + far_enough: false, + }; + } + if let PullToRefreshState::Dragging { distance: drag, .. } = state.clone() { + let dist = drag + delta.y; + state = PullToRefreshState::Dragging { + distance: dist, + far_enough: dist > self.min_refresh_distance, + }; + } + } else { + state = PullToRefreshState::Idle; + } + if any_released { + if let PullToRefreshState::Dragging { + far_enough: enough, .. + } = state.clone() + { + if enough { + state = PullToRefreshState::DoRefresh; + } else { + state = PullToRefreshState::Idle; + } + } else { + state = PullToRefreshState::Idle; + } + } else if let PullToRefreshState::Dragging { + far_enough: enough, .. + } = state.clone() + { + if enough { + state = PullToRefreshState::DoRefresh; + } + } + } else { + state = PullToRefreshState::Idle; + } + } else { + state = PullToRefreshState::Idle; + } + + if self.loading { + state = PullToRefreshState::Refreshing; + } + + let spinner_size = Vec2::splat(24.0); + + let progress_for_offset = match &state { + PullToRefreshState::Idle => 0.0, + PullToRefreshState::Dragging { .. } => { + state.progress(self.min_refresh_distance).unwrap_or(1.0) + } + PullToRefreshState::DoRefresh => 1.0, + PullToRefreshState::Refreshing => 1.0, + } as f32; + + let anim_progress = ui.ctx().animate_value_with_time( + self.id.with("offset_top"), + progress_for_offset, + ui.style().animation_time, + ); + + let offset_top = -spinner_size.y + spinner_size.y * anim_progress * 2.0; + + if anim_progress > 0.0 { + Area::new(Id::new("Pull to refresh indicator")) + .fixed_pos(content_rect.center_top()) + .pivot(Align2::CENTER_TOP) + .show(ui.ctx(), |ui| { + let (rect, _) = ui.allocate_exact_size(spinner_size, Sense::hover()); + + ui.set_clip_rect(Rect::everything_below(rect.min.y)); + + let rect = rect.translate(Vec2::new(0.0, offset_top)); + + ui.painter().circle( + rect.center(), + spinner_size.x / 1.5, + ui.style().visuals.widgets.inactive.bg_fill, + ui.visuals().widgets.inactive.bg_stroke, + ); + + let mut spinner_color = ui.style().visuals.widgets.inactive.fg_stroke.color; + if anim_progress < 1.0 { + spinner_color = Color32::from_rgba_unmultiplied( + spinner_color.r(), + spinner_color.g(), + spinner_color.b(), + (spinner_color.a() as f32 * 0.7).round() as u8, + ); + } + ProgressSpinner::new() + .color(spinner_color) + .progress(state.progress(self.min_refresh_distance)) + .paint_at(ui, rect); + }); + } + + ui.data_mut(|data| { + data.insert_temp(self.id, state.clone()); + }); + + state + } +} diff --git a/src/gui/views/qr.rs b/src/gui/views/qr.rs new file mode 100644 index 00000000..82f1ae67 --- /dev/null +++ b/src/gui/views/qr.rs @@ -0,0 +1,525 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::epaint::RectShape; +use egui::{SizeHint, TextureHandle, UiBuilder}; +use image::codecs::png::{CompressionType, FilterType, PngEncoder}; +use image::{ExtendedColorType, ImageEncoder}; +use parking_lot::RwLock; +use qrcodegen::QrCode; +use std::mem::size_of; +use std::sync::Arc; +use std::thread; + +use crate::gui::Colors; +use crate::gui::icons::{COPY, IMAGES_SQUARE}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::View; +use crate::gui::views::types::QrImageState; + +/// QR code image from text. +pub struct QrCodeContent { + /// QR code text. + pub text: String, + /// Flag to show text below QR code. + show_text: bool, + /// Flag to copy text below QR code. + can_copy_text: bool, + + /// Maximum QR code size. + max_size: f32, + + /// Flag to draw animated QR with Uniform Resources + /// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md + animated: bool, + /// Index of current image at animation. + animated_index: Option, + /// Time of last image draw. + animation_time: Option, + + /// Texture handle to show image when created. + texture_handle: Option, + /// QR code view data state. + qr_image_state: Arc>, +} + +const DEFAULT_QR_SIZE: u32 = 512; + +impl QrCodeContent { + pub fn new(text: String, animated: bool) -> Self { + Self { + text, + show_text: true, + can_copy_text: true, + max_size: DEFAULT_QR_SIZE as f32, + animated, + animated_index: None, + animation_time: None, + texture_handle: None, + qr_image_state: Arc::new(RwLock::new(QrImageState::default())), + } + } + + /// Setup maximum QR code size. + pub fn with_max_size(mut self, max_size: f32) -> Self { + self.max_size = max_size; + self + } + + /// Hide text below QR code. + pub fn hide_text(mut self) -> Self { + self.show_text = false; + self + } + + /// Do not show button to copy QR code text. + pub fn no_copy(mut self) -> Self { + self.can_copy_text = false; + self + } + + /// Draw QR code. + pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + if self.animated { + // Show animated QR code. + self.animated_ui(ui, cb); + } else { + // Show static QR code. + self.static_ui(ui, cb); + } + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + } + + /// Draw animated QR code content. + fn animated_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + if !self.has_image() { + let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0; + ui.vertical_centered(|ui| { + ui.add_space(space); + View::big_loading_spinner(ui); + ui.add_space(space); + }); + + // Create multiple vector images from text if not creating. + if !self.loading() { + self.create_svg_list(); + } + } else { + let svg_list = { + let r_create = self.qr_image_state.read(); + r_create.svg_list.clone().unwrap() + }; + + // Setup animated index. + let now = chrono::Utc::now().timestamp_millis(); + if now - *self.animation_time.get_or_insert(now) > 100 { + if let Some(i) = self.animated_index { + self.animated_index = Some(i + 1); + } + if *self.animated_index.get_or_insert(0) == svg_list.len() { + self.animated_index = Some(0); + } + self.animation_time = Some(now); + } + + let svg = svg_list[self.animated_index.unwrap_or(0)].clone(); + + // Create images from SVG data. + self.qr_image_ui(svg, ui); + + // Show QR code text. + if self.show_text { + self.text_ui(ui); + } + + ui.vertical_centered(|ui| { + let sharing = { + let r_state = self.qr_image_state.read(); + r_state.exporting || r_state.gif_creating + }; + if !sharing { + ui.vertical_centered(|ui| { + // Show button to share QR. + let share_text = format!("{} {}", IMAGES_SQUARE, t!("share")); + View::colored_text_button( + ui, + share_text, + Colors::blue(), + Colors::white_or_black(false), + || { + { + let mut w_state = self.qr_image_state.write(); + w_state.exporting = true; + } + // Create GIF to export. + self.create_qr_gif(); + }, + ); + }); + } else { + ui.vertical_centered(|ui| { + ui.add_space(2.0); + View::small_loading_spinner(ui); + ui.add_space(1.0); + }); + } + + // Check if GIF was created to share. + let has_gif = { + let r_state = self.qr_image_state.read(); + r_state.gif_data.is_some() + }; + if has_gif { + let data = { + let r_state = self.qr_image_state.read(); + r_state.gif_data.clone().unwrap() + }; + let name = format!("{}.gif", chrono::Utc::now().timestamp()); + cb.share_data(name, data).unwrap_or_default(); + // Clear GIF data and exporting flag. + { + let mut w_state = self.qr_image_state.write(); + w_state.gif_data = None; + w_state.exporting = false; + } + } + }); + + ui.ctx().request_repaint(); + } + } + + /// Draw static QR code content. + fn static_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + if !self.has_image() { + let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0; + ui.vertical_centered(|ui| { + ui.add_space(space); + View::big_loading_spinner(ui); + ui.add_space(space); + }); + + // Create vector image from text if not creating. + if !self.loading() { + self.create_svg(); + } + } else { + // Create image from SVG data. + let svg = { + let r_state = self.qr_image_state.read(); + r_state.svg.clone().unwrap() + }; + self.qr_image_ui(svg, ui); + + // Show QR code text. + if self.show_text { + self.text_ui(ui); + } else { + ui.add_space(8.0); + } + + if self.can_copy_text { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(6.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + // Draw copy button. + let copy_text = format!("{} {}", COPY, t!("copy")); + View::button(ui, copy_text, Colors::white_or_black(false), || { + cb.copy_string_to_buffer(self.text.clone()); + }); + }); + columns[1].vertical_centered_justified(|ui| { + self.share_static_button_ui(ui, cb); + }); + }); + } else { + ui.vertical_centered(|ui| { + self.share_static_button_ui(ui, cb); + }); + } + } + } + + /// Draw button to share static QR code. + fn share_static_button_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let share_text = format!("{} {}", IMAGES_SQUARE, t!("share")); + View::colored_text_button( + ui, + share_text, + Colors::blue(), + Colors::white_or_black(false), + || { + self.share_static(cb); + }, + ); + } + + /// Share static QR code image. + fn share_static(&self, cb: &dyn PlatformCallbacks) { + let text = self.text.as_str(); + if let Ok(qr) = QrCode::encode_text(text, qrcodegen::QrCodeEcc::Low) { + let size = DEFAULT_QR_SIZE as usize; + if let Some(data) = Self::qr_to_image_data(qr, size) { + let mut png = vec![]; + let png_enc = PngEncoder::new_with_quality( + &mut png, + CompressionType::Best, + FilterType::NoFilter, + ); + if let Ok(()) = png_enc.write_image( + data.as_slice(), + DEFAULT_QR_SIZE, + DEFAULT_QR_SIZE, + ExtendedColorType::L8, + ) { + let name = format!("{}.png", chrono::Utc::now().timestamp()); + cb.share_data(name, png).unwrap_or_default(); + } + } + } + } + + /// Draw QR code image content. + fn qr_image_ui(&mut self, svg: Vec, ui: &mut egui::Ui) { + View::max_width_ui(ui, self.max_size, |ui| { + let mut rect = ui.available_rect_before_wrap(); + rect.min += egui::emath::vec2(10.0, 0.0); + rect.max -= egui::emath::vec2(10.0, 0.0); + + // Create background shape. + let mut bg_shape = RectShape::new( + rect, + egui::CornerRadius::default(), + egui::Color32::WHITE, + egui::Stroke::NONE, + egui::StrokeKind::Outside, + ); + let bg_idx = ui.painter().add(bg_shape.clone()); + + // Draw QR code image. + let mut content_rect = ui + .scope_builder(UiBuilder::new().max_rect(rect), |ui| { + ui.add_space(10.0); + let size = SizeHint::Size { + width: ui.available_width() as u32, + height: ui.available_width() as u32, + maintain_aspect_ratio: true, + }; + self.texture_handle = Some(View::svg_image(ui, "qr", svg.as_slice(), size)); + ui.add_space(10.0); + }) + .response + .rect; + + // Setup background size. + content_rect.min -= egui::emath::vec2(10.0, 0.0); + content_rect.max += egui::emath::vec2(10.0, 0.0); + bg_shape.rect = content_rect; + ui.painter().set(bg_idx, bg_shape); + }); + } + + /// Draw QR code text. + fn text_ui(&self, ui: &mut egui::Ui) { + ui.add_space(6.0); + View::ellipsize_text(ui, self.text.clone(), 15.0, Colors::inactive_text()); + ui.add_space(6.0); + } + + /// Check if QR code is loading. + fn loading(&self) -> bool { + let r_state = self.qr_image_state.read(); + r_state.loading + } + + /// Create multiple vector QR code images at separate thread. + fn create_svg_list(&self) { + let qr_state = self.qr_image_state.clone(); + let text = self.text.clone(); + thread::spawn(move || { + let mut encoder = ur::Encoder::bytes(text.as_bytes(), 64).unwrap(); + let mut data = Vec::with_capacity(encoder.fragment_count()); + for _ in 0..encoder.fragment_count() { + let ur = encoder.next_part().unwrap(); + if let Ok(qr) = QrCode::encode_text(ur.as_str(), qrcodegen::QrCodeEcc::Low) { + let svg = Self::qr_to_svg(qr, 0); + data.push(svg.into_bytes()); + } + } + let mut w_state = qr_state.write(); + if !data.is_empty() { + w_state.svg_list = Some(data); + } + w_state.loading = false; + }); + } + + /// Check if image was created. + fn has_image(&self) -> bool { + let r_state = self.qr_image_state.read(); + r_state.svg.is_some() || r_state.svg_list.is_some() + } + + /// Create vector QR code image at separate thread. + fn create_svg(&self) { + let qr_state = self.qr_image_state.clone(); + let text = self.text.clone(); + thread::spawn(move || { + if let Ok(qr) = QrCode::encode_text(text.as_str(), qrcodegen::QrCodeEcc::Low) { + let svg = Self::qr_to_svg(qr, 0); + let mut w_state = qr_state.write(); + w_state.loading = false; + w_state.svg = Some(svg.into_bytes()); + } + }); + } + + /// Convert QR code to SVG string. + fn qr_to_svg(qr: QrCode, border: i32) -> String { + let mut result = String::new(); + let dimension = qr + .size() + .checked_add(border.checked_mul(2).unwrap()) + .unwrap(); + result += "\n"; + result += "\n"; + result += &format!( + "\n", + dimension + ); + result += "\t\n"; + result += "\t\n"; + result += "\n"; + result + } + + /// Create GIF image at separate thread. + fn create_qr_gif(&self) { + { + let mut w_state = self.qr_image_state.write(); + w_state.gif_creating = true; + } + let qr_state = self.qr_image_state.clone(); + let text = self.text.clone(); + thread::spawn(move || { + // Setup GIF image encoder. + let mut gif = vec![]; + { + // Generate QR codes from text. + let mut qrs = vec![]; + let mut ur_enc = ur::Encoder::bytes(text.as_bytes(), 100).unwrap(); + for _ in 0..ur_enc.fragment_count() { + let ur = ur_enc.next_part().unwrap(); + if let Ok(qr) = qrcode::QrCode::with_error_correction_level( + ur.as_bytes(), + qrcode::EcLevel::L, + ) { + // Create an image from QR data. + let image = qr + .render() + .max_dimensions(DEFAULT_QR_SIZE, DEFAULT_QR_SIZE) + .dark_color(image::Rgb([0, 0, 0])) + .light_color(image::Rgb([255, 255, 255])) + .build(); + qrs.push(image); + } + } + + if !qrs.is_empty() { + // Generate GIF data. + let color_map = &[0, 0, 0, 0xFF, 0xFF, 0xFF]; + let mut gif_enc = gif::Encoder::new( + &mut gif, + qrs[0].width() as u16, + qrs[0].height() as u16, + color_map, + ) + .unwrap(); + gif_enc.set_repeat(gif::Repeat::Infinite).unwrap(); + for qr in qrs { + let mut frame = gif::Frame::from_rgb( + qr.width() as u16, + qr.height() as u16, + qr.as_raw().as_slice(), + ); + frame.delay = 10; + // Write an image to GIF encoder. + if let Ok(_) = gif_enc.write_frame(&frame) { + continue; + } + // Exit on error. + let mut w_state = qr_state.write(); + w_state.gif_creating = false; + return; + } + } + } + // Setup GIF image data. + let mut w_state = qr_state.write(); + if !gif.is_empty() { + w_state.gif_data = Some(gif); + } + w_state.gif_creating = false; + }); + } + + /// Convert QR code to image data. + fn qr_to_image_data(qr: QrCode, size: usize) -> Option> { + if size >= 2usize.pow((size_of::() * 4) as u32) { + return None; + } + let margin_size = 1; + let s = qr.size(); + let data_length = s as usize; + let data_length_with_margin = data_length + 2 * margin_size; + let point_size = size / data_length_with_margin; + if point_size == 0 { + return None; + } + let margin = (size - (point_size * data_length)) / 2; + let length = size * size; + let mut img_raw: Vec = vec![255u8; length]; + for i in 0..s { + for j in 0..s { + if qr.get_module(i, j) { + let x = i as usize * point_size + margin; + let y = j as usize * point_size + margin; + + for j in y..(y + point_size) { + let offset = j * size; + for i in x..(x + point_size) { + img_raw[offset + i] = 0; + } + } + } + } + } + Some(img_raw) + } +} diff --git a/src/gui/views/scan.rs b/src/gui/views/scan.rs new file mode 100644 index 00000000..7aa81722 --- /dev/null +++ b/src/gui/views/scan.rs @@ -0,0 +1,152 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Id, ScrollArea}; + +use crate::gui::Colors; +use crate::gui::icons::COPY; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::QrScanResult; +use crate::gui::views::{CameraContent, Modal, View}; + +/// QR code scanning content. +pub struct CameraScanContent { + /// Camera content. + camera_content: Option, + /// Scan result. + qr_scan_result: Option, +} + +impl Default for CameraScanContent { + fn default() -> Self { + Self { + camera_content: Some(CameraContent::default()), + qr_scan_result: None, + } + } +} + +impl CameraScanContent { + /// Draw [`Modal`] content. + pub fn modal_ui( + &mut self, + ui: &mut egui::Ui, + cb: &dyn PlatformCallbacks, + mut on_result: impl FnMut(&QrScanResult), + ) { + // Show scan result if exists or show camera content while scanning. + if let Some(result) = &self.qr_scan_result.clone() { + Self::result_ui( + ui, + result, + cb, + || { + Modal::close(); + }, + || { + self.qr_scan_result = None; + cb.start_camera(); + Modal::set_title(t!("scan_qr")); + }, + ); + } else if let Some(camera_content) = self.camera_content.as_mut() { + if let Some(result) = camera_content.qr_scan_result() { + cb.stop_camera(); + self.camera_content = None; + on_result(&result); + + // Set result and rename modal title. + self.qr_scan_result = Some(result); + Modal::set_title(t!("scan_result")); + } else { + // Draw camera content. + ui.add_space(6.0); + self.camera_content.as_mut().unwrap().ui(ui, cb); + ui.add_space(12.0); + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + cb.stop_camera(); + self.camera_content = None; + Modal::close(); + }, + ); + }); + } + } + ui.add_space(6.0); + } + + /// Draw scan result content. + pub fn result_ui( + ui: &mut egui::Ui, + result: &QrScanResult, + cb: &dyn PlatformCallbacks, + on_close: impl FnOnce(), + on_repeat: impl FnOnce(), + ) { + let mut result_text = result.text(); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(3.0); + ScrollArea::vertical() + .id_salt(Id::from("qr_scan_result_input")) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .max_height(128.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(7.0); + egui::TextEdit::multiline(&mut result_text) + .font(egui::TextStyle::Small) + .desired_rows(5) + .interactive(false) + .desired_width(f32::INFINITY) + .show(ui); + ui.add_space(6.0); + }); + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(10.0); + + // Show copy button. + ui.vertical_centered(|ui| { + let copy_text = format!("{} {}", COPY, t!("copy")); + View::button(ui, copy_text, Colors::white_or_black(false), || { + cb.copy_string_to_buffer(result_text.to_string()); + }); + }); + ui.add_space(10.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + on_close(); + }); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("repeat"), Colors::white_or_black(false), || { + on_repeat(); + }); + }); + }); + } +} diff --git a/src/gui/views/settings/content.rs b/src/gui/views/settings/content.rs new file mode 100644 index 00000000..f10865ba --- /dev/null +++ b/src/gui/views/settings/content.rs @@ -0,0 +1,112 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{DATABASE, FADERS, GLOBE_SIMPLE, POWER}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::NetworkContent; +use crate::gui::views::settings::{InterfaceSettingsContent, NetworkSettingsContent}; +use crate::gui::views::types::ContentContainer; +use crate::gui::views::{Content, View}; +use crate::node::Node; + +/// Application settings content. +pub struct SettingsContent { + /// User interface settings. + interface_settings: InterfaceSettingsContent, + /// Network communication settings. + network_settings: NetworkSettingsContent, +} + +impl Default for SettingsContent { + fn default() -> Self { + Self { + interface_settings: InterfaceSettingsContent::default(), + network_settings: NetworkSettingsContent::default(), + } + } +} + +impl SettingsContent { + /// Draw application settings content. + pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + ui.add_space(5.0); + View::checkbox(ui, AppConfig::check_updates(), t!("check_updates"), || { + AppConfig::toggle_check_updates(); + }); + ui.add_space(6.0); + View::horizontal_line(ui, Colors::stroke()); + + // Show interface settings. + self.interface_settings.ui(ui, cb); + + ui.add_space(8.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + View::sub_title(ui, format!("{} {}", GLOBE_SIMPLE, t!("network.self"))); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + // Show network settings. + self.network_settings.ui(ui, cb); + ui.add_space(8.0); + + // Integrated node — relocated here from the wallet-list chip so the + // list stays uncluttered. Quick status + enable/autorun, plus a button + // into the full node panel (stats, mining, tuning, recovery). + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + View::sub_title(ui, format!("{} {}", DATABASE, t!("network.node"))); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(8.0); + + let running = Node::is_running(); + let (status_color, status_text) = if !running { + (Colors::gray(), "Disabled") + } else if Node::not_syncing() { + (Colors::pos(), "Running · synced") + } else { + (Colors::gold(), "Running · syncing…") + }; + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(status_text) + .size(15.0) + .color(status_color), + ); + }); + ui.add_space(8.0); + + if !running { + View::action_button( + ui, + format!("{} {}", POWER, t!("network.enable_node")), + || { + Node::start(); + }, + ); + ui.add_space(4.0); + } + NetworkContent::autorun_node_ui(ui); + ui.add_space(8.0); + View::action_button(ui, format!("{} {}", FADERS, t!("network.settings")), || { + if !Content::is_network_panel_open() { + Content::toggle_network_panel(); + } + }); + ui.add_space(8.0); + } +} diff --git a/src/gui/views/settings/interface.rs b/src/gui/views/settings/interface.rs new file mode 100644 index 00000000..bdba0314 --- /dev/null +++ b/src/gui/views/settings/interface.rs @@ -0,0 +1,149 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use eframe::epaint::RectShape; +use egui::{Align, CursorIcon, Layout, RichText, Sense, StrokeKind, UiBuilder}; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::ContentContainer; +use crate::gui::views::{Modal, View}; + +/// User interface settings content. +pub struct InterfaceSettingsContent { + /// Current locale. + locale: String, +} + +impl ContentContainer for InterfaceSettingsContent { + fn modal_ids(&self) -> Vec<&'static str> { + vec![] + } + + fn modal_ui(&mut self, _: &mut egui::Ui, _: &Modal, _: &dyn PlatformCallbacks) {} + + fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + ui.add_space(5.0); + + // Draw theme selection. + ui.vertical_centered(|ui| { + ui.label(RichText::new(t!("theme")).size(16.0).color(Colors::gray())); + }); + + let saved_use_dark = AppConfig::dark_theme().unwrap_or(false); + let mut selected_use_dark = saved_use_dark; + + ui.add_space(8.0); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + View::radio_value(ui, &mut selected_use_dark, false, t!("light")); + }); + columns[1].vertical_centered(|ui| { + View::radio_value(ui, &mut selected_use_dark, true, t!("dark")); + }) + }); + ui.add_space(14.0); + if saved_use_dark != selected_use_dark { + AppConfig::set_dark_theme(selected_use_dark); + crate::setup_visuals(ui.ctx()); + } + + // Draw language selection. + let locales = rust_i18n::available_locales!(); + for (index, locale) in locales.iter().enumerate() { + self.language_item_ui(locale, ui, index, locales.len()); + } + ui.add_space(4.0); + } +} + +impl Default for InterfaceSettingsContent { + fn default() -> Self { + let locale = if let Some(lang) = AppConfig::locale() { + lang + } else { + rust_i18n::locale().to_string() + }; + Self { locale } + } +} + +impl InterfaceSettingsContent { + /// Draw language selection item content. + fn language_item_ui(&mut self, locale: &str, ui: &mut egui::Ui, index: usize, len: usize) { + let is_current = self.locale == locale; + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(50.0); + let r = View::item_rounding(index, len, false); + let bg = if is_current { + Colors::fill() + } else { + Colors::fill_lite() + }; + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + if is_current { + View::selected_item_check(ui); + } + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + ui.add_space(12.0); + ui.vertical(|ui| { + // Draw language name. + ui.add_space(12.0); + let color = if is_current { + Colors::title(false) + } else { + Colors::gray() + }; + ui.label( + RichText::new(t!("lang_name", locale = locale)) + .size(17.0) + .color(color), + ); + ui.add_space(14.0); + }); + }, + ); + }, + ) + .response; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if res.hovered() && !is_current { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::fill(); + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked && !is_current { + rust_i18n::set_locale(locale); + AppConfig::save_locale(locale); + self.locale = locale.to_string(); + } + } +} diff --git a/src/gui/views/settings/mod.rs b/src/gui/views/settings/mod.rs new file mode 100644 index 00000000..f1339671 --- /dev/null +++ b/src/gui/views/settings/mod.rs @@ -0,0 +1,22 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +mod content; +pub use content::*; + +mod interface; +pub use interface::*; + +mod network; +pub use network::*; diff --git a/src/gui/views/settings/network.rs b/src/gui/views/settings/network.rs new file mode 100644 index 00000000..68aebeec --- /dev/null +++ b/src/gui/views/settings/network.rs @@ -0,0 +1,311 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use eframe::epaint::RectShape; +use egui::{Align, CursorIcon, Id, Layout, RichText, Sense, StrokeKind, UiBuilder}; +use url::Url; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{CLOUD_CHECK, CLOUD_SLASH}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{Modal, TextEdit, View}; + +/// Network communication settings content. +pub struct NetworkSettingsContent { + /// Proxy URL input value for [`Modal`]. + proxy_url_edit: String, + /// Flag to check if entered proxy address was correct. + proxy_url_error: bool, +} + +/// Identifier for proxy URL edit [`Modal`]. +const PROXY_URL_EDIT_MODAL: &'static str = "settings_proxy_edit_modal"; + +impl ContentContainer for NetworkSettingsContent { + fn modal_ids(&self) -> Vec<&'static str> { + vec![PROXY_URL_EDIT_MODAL] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + PROXY_URL_EDIT_MODAL => self.proxy_modal_ui(ui, cb), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + let use_proxy = AppConfig::use_proxy(); + View::checkbox(ui, use_proxy, t!("app_settings.proxy"), || { + // Show edit modal when both URLs are empty. + if AppConfig::http_proxy_url().is_none() + && AppConfig::socks_proxy_url().is_none() + && !use_proxy + { + Modal::new(PROXY_URL_EDIT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("app_settings.proxy")) + .show(); + } else { + AppConfig::toggle_use_proxy(); + } + }); + if !use_proxy { + ui.add_space(4.0); + ui.label( + RichText::new(t!("app_settings.proxy_desc")) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(8.0); + } else { + ui.add_space(8.0); + + // Draw proxy type selection. + Self::proxy_type_ui(ui); + + // Draw proxy URL info. + self.proxy_item_ui(ui); + ui.add_space(6.0); + } + } +} + +impl Default for NetworkSettingsContent { + fn default() -> Self { + Self { + proxy_url_edit: "".to_string(), + proxy_url_error: false, + } + } +} + +impl NetworkSettingsContent { + /// Draw proxy edit modal content. + fn proxy_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut NetworkSettingsContent| { + let proxy = c.proxy_url_edit.trim().to_string(); + let use_socks = AppConfig::use_socks_proxy(); + // Clear value if empty. + if proxy.is_empty() { + if use_socks { + AppConfig::save_socks_proxy_url(None); + } else { + AppConfig::save_http_proxy_url(None); + } + Modal::close(); + return; + } + // Format URL. + let http = "http://"; + let socks = "socks5://"; + let url = if use_socks { + let p = proxy.replace(http, ""); + if !p.contains(socks) { + format!("{}{}", socks, p) + } else { + p + } + } else { + let p = proxy.replace(socks, ""); + if !p.contains(http) { + format!("{}{}", http, p) + } else { + p + } + }; + c.proxy_url_error = Url::parse(url.as_str()).is_err(); + if !c.proxy_url_error { + // Save result when no error. + if !AppConfig::use_proxy() { + AppConfig::toggle_use_proxy(); + } + if use_socks { + AppConfig::save_socks_proxy_url(Some(url)) + } else { + AppConfig::save_http_proxy_url(Some(url)); + } + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let label = format!("{}:", t!("enter_url")); + ui.label(RichText::new(label).size(17.0).color(Colors::gray())); + ui.add_space(8.0); + + // Draw proxy URL text edit. + let mut edit = + TextEdit::new(Id::from("proxy_url_edit").with(PROXY_URL_EDIT_MODAL).with( + if AppConfig::use_proxy() { + "socks5" + } else { + "http" + }, + )) + .paste(); + edit.ui(ui, &mut self.proxy_url_edit, cb); + if edit.enter_pressed { + on_save(self); + } + + // Show error when specified address is incorrect. + if self.proxy_url_error { + ui.add_space(10.0); + ui.label( + RichText::new(t!("wallets.invalid_url")) + .size(16.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + + // Show type selection when both URLs are empty. + if AppConfig::socks_proxy_url().is_none() && AppConfig::http_proxy_url().is_none() { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + Self::proxy_type_ui(ui); + }); + ui.add_space(4.0); + } + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw proxy item content. + fn proxy_item_ui(&mut self, ui: &mut egui::Ui) { + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(56.0); + let r = View::item_rounding(0, 1, false); + let bg = Colors::fill_lite(); + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(4.0); + let use_socks = AppConfig::use_socks_proxy(); + let proxy_url = if use_socks { + AppConfig::socks_proxy_url() + } else { + AppConfig::http_proxy_url() + }; + let (url, color, icon, text) = if let Some(url) = proxy_url { + ( + url, + Colors::title(false), + CLOUD_CHECK, + t!("network_settings.enabled"), + ) + } else { + ( + t!("enter_url").into(), + Colors::inactive_text(), + CLOUD_SLASH, + t!("network_settings.disabled"), + ) + }; + View::ellipsize_text(ui, url, 18.0, color); + ui.add_space(1.0); + + let value = format!("{} {}", icon, text); + ui.label(RichText::new(value).size(15.0).color(Colors::gray())); + ui.add_space(3.0); + }); + }, + ); + }, + ) + .response; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if res.hovered() { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::fill(); + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked { + let url = if AppConfig::use_socks_proxy() { + AppConfig::socks_proxy_url().unwrap_or("".to_string()) + } else { + AppConfig::http_proxy_url().unwrap_or("".to_string()) + }; + self.proxy_url_edit = url; + // Show proxy URL edit modal. + Modal::new(PROXY_URL_EDIT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("app_settings.proxy")) + .show(); + } + } + + /// Draw proxy type selection. + fn proxy_type_ui(ui: &mut egui::Ui) { + // Draw proxy type selection. + let saved_use_socks = AppConfig::use_socks_proxy(); + let mut selected_use_socks = saved_use_socks; + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + View::radio_value(ui, &mut selected_use_socks, true, "SOCKS5".to_string()); + }); + columns[1].vertical_centered(|ui| { + View::radio_value(ui, &mut selected_use_socks, false, "HTTP".to_string()); + }) + }); + ui.add_space(14.0); + if saved_use_socks != selected_use_socks { + AppConfig::toggle_use_socks_proxy(); + } + } +} diff --git a/src/gui/views/title_panel.rs b/src/gui/views/title_panel.rs new file mode 100644 index 00000000..92cc03b1 --- /dev/null +++ b/src/gui/views/title_panel.rs @@ -0,0 +1,122 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::{Align, Id, Layout, Margin, UiBuilder}; + +use crate::gui::Colors; +use crate::gui::views::types::{TitleContentType, TitleType}; +use crate::gui::views::{Content, View}; + +/// Title panel with left/right action buttons and text in the middle. +pub struct TitlePanel { + /// Widget identifier. + id: Id, +} + +impl TitlePanel { + /// Content height. + pub const HEIGHT: f32 = 54.0; + + /// Create new title panel with provided identifier. + pub fn new(id: Id) -> Self { + Self { id } + } + + pub fn ui( + &self, + title: TitleType, + mut left_content: impl FnMut(&mut egui::Ui), + mut right_content: impl FnMut(&mut egui::Ui), + ui: &mut egui::Ui, + ) { + // Draw title panel. + egui::TopBottomPanel::top(self.id) + .resizable(false) + .exact_height(Self::HEIGHT + View::get_top_inset()) + .frame(egui::Frame { + inner_margin: Margin { + left: View::far_left_inset_margin(ui) as i8, + right: View::far_right_inset_margin(ui) as i8, + top: View::get_top_inset() as i8, + bottom: 0.0 as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + let rect = ui.available_rect_before_wrap(); + ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Max), |ui| { + ui.horizontal_centered(|ui| { + (right_content)(ui); + }); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.horizontal_centered(|ui| { + (left_content)(ui); + }); + }); + match title { + TitleType::Single(content) => { + let content_rect = { + let mut r = rect.clone(); + r.min.x += Self::HEIGHT; + r.max.x -= Self::HEIGHT; + r + }; + ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| { + Self::title_text_content(ui, content); + }); + } + TitleType::Dual(first, second) => { + let first_rect = { + let mut r = rect.clone(); + r.max.x = r.min.x + Content::SIDE_PANEL_WIDTH - Self::HEIGHT; + r.min.x += Self::HEIGHT; + r + }; + // Draw first title content. + ui.scope_builder(UiBuilder::new().max_rect(first_rect), |ui| { + Self::title_text_content(ui, first); + }); + + let second_rect = { + let mut r = rect.clone(); + r.min.x = first_rect.max.x + 2.0 * Self::HEIGHT; + r.max.x -= Self::HEIGHT; + r + }; + // Draw second title content. + ui.scope_builder(UiBuilder::new().max_rect(second_rect), |ui| { + Self::title_text_content(ui, second); + }); + } + } + }); + }); + } + + /// Setup title text content. + fn title_text_content(ui: &mut egui::Ui, content: TitleContentType) { + ui.vertical_centered(|ui| match content { + TitleContentType::Title(text) => { + ui.add_space(13.0 + if !View::is_desktop() { 1.0 } else { 0.0 }); + View::ellipsize_text(ui, text.to_uppercase(), 19.0, Colors::title(true)); + } + TitleContentType::WithSubTitle(text, subtitle, animate) => { + ui.add_space(4.0); + View::ellipsize_text(ui, text.to_uppercase(), 18.0, Colors::title(true)); + ui.add_space(-2.0); + View::animate_text(ui, subtitle, 15.0, Colors::text(true), animate) + } + }); + } +} diff --git a/src/gui/views/types.rs b/src/gui/views/types.rs new file mode 100644 index 00000000..f82463a1 --- /dev/null +++ b/src/gui/views/types.rs @@ -0,0 +1,155 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::Modal; +use grin_util::ZeroingString; + +/// Title type, can be single or dual title in the row. +pub enum TitleType { + /// Single title content. + Single(TitleContentType), + /// Dual title content, will align first content for default panel size width. + Dual(TitleContentType, TitleContentType), +} + +/// Title content type, can be single title or with animated subtitle. +pub enum TitleContentType { + /// Single text. + Title(String), + /// With optionally animated subtitle text. + WithSubTitle(String, String, bool), +} + +/// Stroke position against content. +pub enum LinePosition { + TOP, + LEFT, + RIGHT, + BOTTOM, +} + +/// Position of [`Modal`] on the screen. +#[derive(Clone)] +pub enum ModalPosition { + CenterTop, + Center, +} + +/// Global [`Modal`] state. +#[derive(Default)] +pub struct ModalState { + /// Opened [`Modal`]. + pub modal: Option, +} + +/// Content container to simplify modals management and navigation. +pub trait ContentContainer { + /// List of allowed [`Modal`] identifiers. + fn modal_ids(&self) -> Vec<&'static str>; + /// Draw modal content. + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks); + /// Draw container content. + fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks); + /// Draw content, to call by parent container. + fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + // Draw modal content. + if let Some(id) = Modal::opened() { + if self.modal_ids().contains(&id) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.modal_ui(ui, modal, cb); + }); + } + } + self.container_ui(ui, cb); + } +} + +/// QR code scan result. +#[derive(Clone)] +pub enum QrScanResult { + /// Slatepack message. + Slatepack(String), + /// Slatepack address. + Address(ZeroingString), + /// Parsed text. + Text(ZeroingString), + /// Recovery phrase in standard or compact SeedQR format. + /// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md + SeedQR(ZeroingString), + /// Part of Uniform Resources as URI with current index and total messages amount. + /// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md + URPart(String, usize, usize), +} + +impl QrScanResult { + /// Get text scanning result. + pub fn text(&self) -> String { + match self { + QrScanResult::Slatepack(text) => text.to_string(), + QrScanResult::Address(text) => text.to_string(), + QrScanResult::Text(text) => text.to_string(), + QrScanResult::SeedQR(text) => text.to_string(), + QrScanResult::URPart(uri, _, _) => uri.to_string(), + } + } +} + +/// QR code scanning state. +pub struct QrScanState { + /// Flag to check if image is processing to find QR code. + pub image_processing: bool, + /// Processed QR code result. + pub qr_scan_result: Option, +} + +impl Default for QrScanState { + fn default() -> Self { + Self { + image_processing: false, + qr_scan_result: None, + } + } +} + +/// QR code image data state. +pub struct QrImageState { + /// Flag to check if QR code image is loading. + pub loading: bool, + /// Flag to check if QR code image is exporting. + pub exporting: bool, + + /// Created GIF data from animated QR code. + pub gif_data: Option>, + /// Flag to check if GIF is creating. + pub gif_creating: bool, + + /// Vector image data. + pub svg: Option>, + /// Multiple vector image data for animated QR code. + pub svg_list: Option>>, +} + +impl Default for QrImageState { + fn default() -> Self { + Self { + loading: false, + exporting: false, + gif_data: None, + gif_creating: false, + svg: None, + svg_list: None, + } + } +} diff --git a/src/gui/views/views.rs b/src/gui/views/views.rs new file mode 100644 index 00000000..2a24150b --- /dev/null +++ b/src/gui/views/views.rs @@ -0,0 +1,785 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::emath::GuiRounding; +use egui::epaint::text::TextWrapping; +use egui::epaint::{Color32, FontId, PathShape, PathStroke, RectShape, Stroke}; +use egui::load::SizedTexture; +use egui::os::OperatingSystem; +use egui::text::{LayoutJob, TextFormat}; +use egui::{ + Button, CornerRadius, CursorIcon, Rect, Response, Rgba, RichText, Sense, SizeHint, Spinner, + StrokeKind, TextureHandle, TextureOptions, UiBuilder, Widget, lerp, +}; +use egui_extras::image::load_svg_bytes_with_size; +use lazy_static::lazy_static; +use std::sync::atomic::{AtomicI32, Ordering}; + +use crate::gui::Colors; +use crate::gui::icons::{CHECK_FAT, CHECK_SQUARE, SQUARE}; +use crate::gui::views::types::LinePosition; + +pub struct View; + +impl View { + /// Check if current platform is desktop + pub fn is_desktop() -> bool { + let os = OperatingSystem::from_target_os(); + os != OperatingSystem::Android && os != OperatingSystem::IOS + } + + /// Format timestamp in seconds with local UTC offset. + pub fn format_time(ts: i64) -> String { + let utc_offset = chrono::Local::now().offset().local_minus_utc(); + let utc_time = ts + utc_offset as i64; + let tx_time = chrono::DateTime::from_timestamp(utc_time, 0).unwrap(); + tx_time.format("%d/%m/%Y %H:%M:%S").to_string() + } + + /// Get default stroke around views. + pub fn default_stroke() -> Stroke { + Stroke { + width: 1.0, + color: Colors::stroke(), + } + } + + /// Get default stroke around item buttons. + pub fn item_stroke() -> Stroke { + Stroke { + width: 1.0, + color: Colors::item_stroke(), + } + } + + /// Get stroke for hovered items and buttons. + pub fn hover_stroke() -> Stroke { + Stroke { + width: 1.0, + color: Colors::item_hover(), + } + } + + /// Draw content with maximum width value. + pub fn max_width_ui( + ui: &mut egui::Ui, + max_width: f32, + add_content: impl FnOnce(&mut egui::Ui), + ) { + // Setup content width. + let mut width = ui.available_width(); + if width == 0.0 { + return; + } + let mut rect = ui.available_rect_before_wrap(); + width = f32::min(width, max_width); + rect.set_width(width); + + // Draw content. + ui.vertical_centered(|ui| { + ui.allocate_ui(rect.size(), |ui| { + (add_content)(ui); + }); + }); + } + + /// Get width and height of app window. + pub fn window_size(ctx: &egui::Context) -> (f32, f32) { + let rect = ctx.content_rect(); + (rect.width(), rect.height()) + } + + /// Calculate margin for far left view based on display insets (cutouts). + pub fn far_left_inset_margin(ui: &mut egui::Ui) -> f32 { + if ui.available_rect_before_wrap().min.x == 0.0 { + Self::get_left_inset() + } else { + 0.0 + } + } + + /// Calculate margin for far left view based on display insets (cutouts). + pub fn far_right_inset_margin(ui: &mut egui::Ui) -> f32 { + let container_width = ui.available_rect_before_wrap().max.x as i32; + let window_size = Self::window_size(ui.ctx()); + let display_width = window_size.0 as i32; + // Means end of the screen. + if container_width == display_width { + Self::get_right_inset() + } else { + 0.0 + } + } + + /// Content padding for current platform. + pub fn content_padding() -> f32 { + if View::is_desktop() { 4.0 } else { 8.0 } + } + + /// Cut long text with ﹍ character. + fn ellipsize(text: impl Into, size: f32, color: Color32) -> LayoutJob { + let mut job = LayoutJob::single_section( + text.into(), + TextFormat { + font_id: FontId::proportional(size), + color, + ..Default::default() + }, + ); + job.wrap = TextWrapping { + max_rows: 1, + break_anywhere: true, + overflow_character: Option::from('﹍'), + ..Default::default() + }; + job + } + + /// Draw ellipsized text. + pub fn ellipsize_text(ui: &mut egui::Ui, text: impl Into, size: f32, color: Color32) { + ui.label(Self::ellipsize(text, size, color)); + } + + /// Draw animated ellipsized text. + pub fn animate_text( + ui: &mut egui::Ui, + text: impl Into, + size: f32, + color: Color32, + animate: bool, + ) { + // Setup text color animation if needed. + let (dark, bright) = (0.3, 1.0); + let color_factor = if animate { + lerp(dark..=bright, ui.input(|i| i.time).cos().abs()) as f32 + } else { + bright as f32 + }; + + // Draw subtitle text. + let sub_color_rgba = Rgba::from(color) * color_factor; + let sub_color = Color32::from(sub_color_rgba); + View::ellipsize_text(ui, text, size, sub_color); + + // Repaint delay based on animation status. + if animate { + ui.ctx().request_repaint(); + } + } + + /// Draw horizontally centered subtitle with space below. + pub fn sub_title(ui: &mut egui::Ui, text: String) { + ui.vertical_centered_justified(|ui| { + ui.label( + RichText::new(text.to_uppercase()) + .size(16.0) + .color(Colors::text(false)), + ); + }); + ui.add_space(4.0); + } + + /// Draw big size title button. + pub fn title_button_big(ui: &mut egui::Ui, icon: &str, action: impl FnOnce(&mut egui::Ui)) { + Self::title_button(ui, 22.0, icon, action); + } + + /// Draw small size title button. + pub fn title_button_small(ui: &mut egui::Ui, icon: &str, action: impl FnOnce(&mut egui::Ui)) { + Self::title_button(ui, 16.0, icon, action); + } + + /// Draw title button with transparent background color, contains only icon. + fn title_button(ui: &mut egui::Ui, size: f32, icon: &str, action: impl FnOnce(&mut egui::Ui)) { + ui.scope(|ui| { + // Setup padding for title buttons. + if !View::is_desktop() { + ui.style_mut().spacing.button_padding = egui::vec2(20.0, 8.0); + } else { + ui.style_mut().spacing.button_padding = egui::vec2(16.0, 8.0); + } + // Disable strokes. + ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.active.corner_radius = CornerRadius::default(); + ui.style_mut().visuals.widgets.active.expansion = 0.0; + + // Setup text. + let wt = RichText::new(icon.to_string()) + .size(size) + .color(Colors::title(true)); + // Draw button. + let br = Button::new(wt) + .sense(if View::is_desktop() { + Sense::click() + } else { + Sense::click_and_drag() + }) + .fill(Colors::TRANSPARENT) + .ui(ui) + .on_hover_cursor(CursorIcon::PointingHand); + br.surrender_focus(); + if br.clicked() || (!View::is_desktop() && br.drag_stopped()) { + action(ui); + } + }); + } + + /// Padding for tab items. + pub const TAB_ITEMS_PADDING: f32 = 5.0; + + /// Tab button with white background fill color, contains only icon. + pub fn tab_button( + ui: &mut egui::Ui, + icon: &str, + color: Option, + selected: Option, + action: impl FnOnce(&mut egui::Ui), + ) { + ui.scope(|ui| { + let text_color = if let Some(c) = color { + if selected.is_none() { + Colors::inactive_text() + } else { + c + } + } else { + if let Some(active) = selected { + match active { + true => Colors::gray(), + false => Colors::item_button_text(), + } + } else { + Colors::inactive_text() + } + }; + + let mut button = Button::new(RichText::new(icon).size(22.0).color(text_color)); + + let active_not_selected = selected.is_some() && !selected.unwrap(); + if active_not_selected { + // Disable expansion on click/hover. + ui.style_mut().visuals.widgets.hovered.expansion = 0.0; + ui.style_mut().visuals.widgets.active.expansion = 0.0; + // Setup fill colors. + ui.visuals_mut().widgets.inactive.weak_bg_fill = Colors::white_or_black(false); + ui.visuals_mut().widgets.hovered.weak_bg_fill = Colors::fill_lite(); + ui.visuals_mut().widgets.active.weak_bg_fill = Colors::fill(); + // Setup stroke colors. + ui.visuals_mut().widgets.inactive.bg_stroke = Self::default_stroke(); + ui.visuals_mut().widgets.hovered.bg_stroke = Self::hover_stroke(); + ui.visuals_mut().widgets.active.bg_stroke = Self::item_stroke(); + } else { + button = button.fill(Colors::fill()).stroke(Stroke::NONE); + } + + // Setup paddings for tab buttons. + if Self::is_desktop() { + ui.style_mut().spacing.button_padding = egui::vec2(10.0, 4.0); + } else { + ui.style_mut().spacing.button_padding = egui::vec2(14.0, 8.0); + }; + + // Setup pointer style. + let br = if active_not_selected { + button.ui(ui).on_hover_cursor(CursorIcon::PointingHand) + } else { + button.ui(ui) + }; + + br.surrender_focus(); + if br.clicked() && active_not_selected { + action(ui); + } + }); + } + + /// Draw [`Button`] with specified background fill and text color. + fn button_resp(ui: &mut egui::Ui, text: String, text_color: Color32, bg: Color32) -> Response { + let button_text = Self::ellipsize(text.to_uppercase(), 17.0, text_color); + Button::new(button_text) + .stroke(Self::default_stroke()) + .fill(bg) + .ui(ui) + .on_hover_cursor(CursorIcon::PointingHand) + } + + /// Draw [`Button`] with specified background fill color and default text color. + pub fn button(ui: &mut egui::Ui, text: impl Into, fill: Color32, cb: impl FnOnce()) { + let br = Self::button_resp(ui, text.into(), Colors::text_button(), fill); + if br.clicked() { + cb(); + } + } + + /// Draw [`Button`] with specified background fill color and text color. + pub fn colored_text_button( + ui: &mut egui::Ui, + text: String, + text_color: Color32, + fill: Color32, + action: impl FnOnce(), + ) { + let br = Self::button_resp(ui, text, text_color, fill); + if br.clicked() { + action(); + } + } + + /// Draw [`Button`] with specified background fill color and text color. + pub fn colored_text_button_ui( + ui: &mut egui::Ui, + text: String, + text_color: Color32, + fill: Color32, + action: impl FnOnce(&mut egui::Ui), + ) { + let br = Self::button_resp(ui, text, text_color, fill); + if br.clicked() { + action(ui); + } + } + + /// Draw gold action [`Button`]. + pub fn action_button(ui: &mut egui::Ui, text: impl Into, action: impl FnOnce()) { + Self::colored_text_button(ui, text.into(), Colors::title(true), Colors::gold(), action); + } + + /// Draw [`Button`] with specified background fill color and ui at callback. + pub fn button_ui( + ui: &mut egui::Ui, + text: impl Into, + fill: Color32, + action: impl FnOnce(&mut egui::Ui), + ) { + let button_text = Self::ellipsize(text.into().to_uppercase(), 17.0, Colors::text_button()); + let br = Button::new(button_text) + .stroke(Self::default_stroke()) + .fill(fill) + .ui(ui) + .on_hover_cursor(CursorIcon::PointingHand); + if br.clicked() { + action(ui); + } + } + + /// Draw list item [`Button`] with provided rounding. + pub fn item_button( + ui: &mut egui::Ui, + rounding: CornerRadius, + text: &'static str, + color: Option, + action: impl FnOnce(), + ) { + // Setup button size. + let mut rect = ui.available_rect_before_wrap(); + rect.set_width(42.0); + let button_size = rect.size(); + + ui.scope(|ui| { + // Setup padding for item buttons. + let padding = if Self::is_desktop() { 15.0 } else { 18.0 }; + ui.style_mut().spacing.button_padding = egui::vec2(padding, 0.0); + // Disable expansion on click/hover. + ui.style_mut().visuals.widgets.hovered.expansion = 0.0; + ui.style_mut().visuals.widgets.active.expansion = 0.0; + // Setup fill colors. + ui.visuals_mut().widgets.inactive.weak_bg_fill = Colors::white_or_black(false); + ui.visuals_mut().widgets.hovered.weak_bg_fill = Colors::fill_lite(); + ui.visuals_mut().widgets.active.weak_bg_fill = Colors::fill(); + // Disable strokes. + ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE; + ui.visuals_mut().widgets.hovered.bg_stroke = Stroke::NONE; + ui.visuals_mut().widgets.active.bg_stroke = Stroke::NONE; + + // Setup button text color. + let text_color = if let Some(c) = color { + c + } else { + Colors::item_button_text() + }; + + // Show button. + let br = Button::new(RichText::new(text).size(20.0).color(text_color)) + .corner_radius(rounding) + .min_size(button_size) + .ui(ui) + .on_hover_cursor(CursorIcon::PointingHand); + br.surrender_focus(); + if br.clicked() { + action(); + } + + // Draw stroke. + let r = { + let mut r = ui.available_rect_before_wrap(); + r.min = br.rect.min; + r.min.x += 0.5; + r + }; + Self::line(ui, LinePosition::LEFT, &r, Colors::item_stroke()); + }); + } + + /// Calculate item background/button rounding based on item index. + pub fn item_rounding(index: usize, len: usize, is_button: bool) -> CornerRadius { + let corners = if is_button { + if len == 1 { + [false, true, true, false] + } else if index == 0 { + [false, true, false, false] + } else if index == len - 1 { + [false, false, true, false] + } else { + [false, false, false, false] + } + } else { + if len == 1 { + [true, true, true, true] + } else if index == 0 { + [true, true, false, false] + } else if index == len - 1 { + [false, false, true, true] + } else { + [false, false, false, false] + } + }; + CornerRadius { + nw: if corners[0] { 8.0 as u8 } else { 0.0 as u8 }, + ne: if corners[1] { 8.0 as u8 } else { 0.0 as u8 }, + sw: if corners[3] { 8.0 as u8 } else { 0.0 as u8 }, + se: if corners[2] { 8.0 as u8 } else { 0.0 as u8 }, + } + } + + /// Draw selected item check. + pub fn selected_item_check(ui: &mut egui::Ui) { + let padding = if View::is_desktop() { 14.0 } else { 18.0 }; + ui.add_space(padding); + ui.label(RichText::new(CHECK_FAT).size(20.0).color(Colors::green())); + ui.add_space(padding); + } + + /// Draw rounded box with some value and label in the middle, + /// where is r = (top_left, top_right, bottom_left, bottom_right). + /// | VALUE | + /// | label | + pub fn label_box(ui: &mut egui::Ui, v: impl Into, l: impl Into, r: [bool; 4]) { + let rect = ui.available_rect_before_wrap(); + + // Create background shape. + let mut bg_shape = RectShape::new( + rect, + CornerRadius { + nw: if r[0] { 8.0 as u8 } else { 0.0 as u8 }, + ne: if r[1] { 8.0 as u8 } else { 0.0 as u8 }, + sw: if r[2] { 8.0 as u8 } else { 0.0 as u8 }, + se: if r[3] { 8.0 as u8 } else { 0.0 as u8 }, + }, + Colors::fill(), + Self::item_stroke(), + StrokeKind::Outside, + ); + let bg_idx = ui.painter().add(bg_shape.clone()); + + // Draw box content. + let content_resp = ui + .scope_builder(UiBuilder::new().max_rect(rect), |ui| { + ui.vertical_centered_justified(|ui| { + ui.add_space(4.0); + ui.scope(|ui| { + // Correct vertical spacing between items. + ui.style_mut().spacing.item_spacing.y = -3.0; + + // Draw box value. + let mut job = LayoutJob::single_section( + v.into(), + TextFormat { + font_id: FontId::proportional(17.0), + color: Colors::white_or_black(true), + ..Default::default() + }, + ); + job.wrap = TextWrapping { + max_rows: 1, + break_anywhere: true, + overflow_character: Option::from('﹍'), + ..Default::default() + }; + ui.label(job); + + // Draw box label. + ui.label(RichText::new(l).color(Colors::gray()).size(15.0)); + }); + ui.add_space(2.0); + }); + }) + .response; + + // Setup background shape size. + bg_shape.rect = content_resp.rect; + ui.painter().set(bg_idx, bg_shape); + } + + /// Draw content in the center of current layout with specified width and height. + pub fn center_content(ui: &mut egui::Ui, height: f32, content: impl FnOnce(&mut egui::Ui)) { + ui.vertical_centered(|ui| { + let mut rect = ui.available_rect_before_wrap(); + let side_margin = 28.0; + rect.min += egui::emath::vec2(side_margin, ui.available_height() / 2.0 - height / 2.0); + rect.max -= egui::emath::vec2(side_margin, 0.0); + ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| { + content(ui); + }); + }); + } + + /// Draw loading spinner. + pub fn loading_spinner(ui: &mut egui::Ui, size: f32) { + Spinner::new().size(size).color(Colors::gold()).ui(ui); + } + + /// Size of big loading spinner. + pub const BIG_SPINNER_SIZE: f32 = 104.0; + + /// Draw big gold loading spinner. + pub fn big_loading_spinner(ui: &mut egui::Ui) { + View::loading_spinner(ui, View::BIG_SPINNER_SIZE); + } + + /// Size of big loading spinner. + pub const SMALL_SPINNER_SIZE: f32 = 32.0; + + /// Draw small gold loading spinner. + pub fn small_loading_spinner(ui: &mut egui::Ui) { + View::loading_spinner(ui, View::SMALL_SPINNER_SIZE); + } + + /// Draw the button that looks like checkbox with callback on check. + pub fn checkbox(ui: &mut egui::Ui, checked: bool, text: impl Into, cb: impl FnOnce()) { + let (text_value, color) = match checked { + true => ( + format!("{} {}", CHECK_SQUARE, text.into()), + Colors::text_button(), + ), + false => (format!("{} {}", SQUARE, text.into()), Colors::checkbox()), + }; + + let br = Button::new(RichText::new(text_value).size(17.0).color(color)) + .frame(false) + .stroke(Stroke::NONE) + .fill(Colors::TRANSPARENT) + .ui(ui) + .on_hover_cursor(CursorIcon::PointingHand); + if br.clicked() { + cb(); + } + } + + /// Show a [`RadioButton`]. It is selected if `*current_value == selected_value`. + /// If clicked, `selected_value` is assigned to `*current_value`. + pub fn radio_value( + ui: &mut egui::Ui, + current: &mut T, + value: T, + text: impl Into, + ) { + ui.scope(|ui| { + // Setup background color. + ui.visuals_mut().widgets.inactive.bg_fill = Colors::fill_deep(); + // Draw radio button. + let mut response = ui + .radio(*current == value, text.into()) + .on_hover_cursor(CursorIcon::PointingHand); + if response.clicked() && *current != value { + *current = value; + response.mark_changed(); + } + }); + } + + /// Draw horizontal line. + pub fn horizontal_line(ui: &mut egui::Ui, color: Color32) { + let line_size = egui::Vec2::new(ui.available_width(), 1.0); + let (line_rect, _) = ui.allocate_exact_size(line_size, Sense::hover()); + let painter = ui.painter(); + painter.hline( + line_rect.x_range(), + line_rect + .center() + .y + .round_to_pixels(painter.pixels_per_point()), + Stroke { width: 1.0, color }, + ); + } + + /// Draw line for panel content. + pub fn line(ui: &mut egui::Ui, pos: LinePosition, rect: &Rect, color: Color32) { + let points = match pos { + LinePosition::RIGHT => { + vec![ + { + let mut r = rect.clone(); + r.min.x = r.max.x; + r.min + }, + rect.max, + ] + } + LinePosition::BOTTOM => { + vec![ + { + let mut r = rect.clone(); + r.min.y = r.max.y; + r.min + }, + rect.max, + ] + } + LinePosition::LEFT => { + vec![rect.min, { + let mut r = rect.clone(); + r.max.x = r.min.x; + r.max + }] + } + LinePosition::TOP => { + vec![rect.min, { + let mut r = rect.clone(); + r.max.y = r.min.y; + r.max + }] + } + }; + let stroke = PathShape { + points, + closed: false, + fill: Default::default(), + stroke: PathStroke::new(1.0, color), + }; + ui.painter().add(stroke); + } + + /// Draw SVG image from provided data with optional provided size. + pub fn svg_image(ui: &mut egui::Ui, name: &str, svg: &[u8], size: SizeHint) -> TextureHandle { + let color_img = load_svg_bytes_with_size(svg, size, &usvg::Options::default()).unwrap(); + // Create image texture. + let texture_handle = + ui.ctx() + .load_texture(name, color_img.clone(), TextureOptions::default()); + let img_size = egui::emath::vec2(color_img.width() as f32, color_img.height() as f32); + let sized_img = SizedTexture::new(texture_handle.id(), img_size); + // Add image to content. + ui.add( + egui::Image::from_texture(sized_img) + .max_height(ui.available_width()) + .fit_to_original_size(1.0), + ); + texture_handle + } + + /// Draw application logo image with name and version. + pub fn app_logo_name_version(ui: &mut egui::Ui) { + ui.add_space(-1.0); + // Goblin mark (white master, tinted for the current theme). + let logo = egui::include_image!("../../../img/goblin-logo2.svg"); + // Show application logo and name. + ui.scope(|ui| { + ui.set_opacity(0.9); + egui::Image::new(logo) + .tint(Colors::white_or_black(true)) + .fit_to_exact_size(egui::vec2(150.0, 150.0)) + .ui(ui); + }); + ui.add_space(6.0); + // Build number only — the "GOBLIN" wordmark was redundant with the mark + // above and the title bar, so it's dropped. Kept small and quiet. + ui.label( + RichText::new(format!("Build {}", crate::BUILD)) + .size(13.0) + .color(Colors::title(false)), + ); + } + + /// Draw semi-transparent cover at specified area. + pub fn content_cover_ui( + ui: &mut egui::Ui, + rect: Rect, + id: impl std::hash::Hash, + mut on_click: impl FnMut(), + ) { + let resp = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag()); + if resp.clicked() || resp.dragged() { + on_click(); + } + let shape = RectShape::filled( + resp.rect, + CornerRadius::ZERO, + Colors::semi_transparent().gamma_multiply(0.7), + ); + ui.painter().add(shape); + } + + /// Get top display inset (cutout) size. + pub fn get_top_inset() -> f32 { + TOP_DISPLAY_INSET.load(Ordering::Relaxed) as f32 + } + + /// Get right display inset (cutout) size. + pub fn get_right_inset() -> f32 { + RIGHT_DISPLAY_INSET.load(Ordering::Relaxed) as f32 + } + + /// Get bottom display inset (cutout) size. + pub fn get_bottom_inset() -> f32 { + BOTTOM_DISPLAY_INSET.load(Ordering::Relaxed) as f32 + } + + /// Get left display inset (cutout) size. + pub fn get_left_inset() -> f32 { + LEFT_DISPLAY_INSET.load(Ordering::Relaxed) as f32 + } +} + +lazy_static! { + static ref TOP_DISPLAY_INSET: AtomicI32 = AtomicI32::new(0); + static ref RIGHT_DISPLAY_INSET: AtomicI32 = AtomicI32::new(0); + static ref BOTTOM_DISPLAY_INSET: AtomicI32 = AtomicI32::new(0); + static ref LEFT_DISPLAY_INSET: AtomicI32 = AtomicI32::new(0); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Callback from Java code to update display insets (cutouts). +pub extern "C" fn Java_mw_gri_android_MainActivity_onDisplayInsets( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + cutouts: jni::sys::jarray, +) { + use jni::objects::{JObject, JPrimitiveArray}; + + let mut array: [i32; 4] = [0; 4]; + unsafe { + let j_obj = JObject::from_raw(cutouts); + let j_arr = JPrimitiveArray::from(j_obj); + _env.get_int_array_region(j_arr, 0, array.as_mut()).unwrap(); + } + TOP_DISPLAY_INSET.store(array[0], Ordering::Relaxed); + RIGHT_DISPLAY_INSET.store(array[1], Ordering::Relaxed); + BOTTOM_DISPLAY_INSET.store(array[2], Ordering::Relaxed); + LEFT_DISPLAY_INSET.store(array[3], Ordering::Relaxed); +} diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs new file mode 100644 index 00000000..fe6313d7 --- /dev/null +++ b/src/gui/views/wallets/content.rs @@ -0,0 +1,892 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use eframe::epaint::RectShape; +use egui::os::OperatingSystem; +use egui::scroll_area::ScrollBarVisibility; +use egui::{ + Align, CornerRadius, CursorIcon, Id, Layout, Margin, OpenUrl, ScrollArea, Sense, StrokeKind, + UiBuilder, +}; +use egui_async::Bind; +use std::time::Duration; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{ + ARROW_LEFT, BOOKMARKS, CALENDAR_CHECK, CLOUD_ARROW_DOWN, COMPUTER_TOWER, GEAR, GEAR_FINE, + GLOBE_SIMPLE, LOCK_KEY, NOTEPAD, PLUS, SIDEBAR_SIMPLE, SUITCASE, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::goblin::onboarding::OnboardingContent; +use crate::gui::views::settings::SettingsContent; +use crate::gui::views::types::{ + ContentContainer, LinePosition, ModalPosition, TitleContentType, TitleType, +}; +use crate::gui::views::wallets::WalletContent; +use crate::gui::views::wallets::creation::WalletCreationContent; +use crate::gui::views::wallets::modals::{ + AddWalletModal, ChangelogContent, OpenWalletModal, WalletListModal, WalletSettingsModal, +}; +use crate::gui::views::wallets::wallet::RecoverySettings; +use crate::gui::views::wallets::wallet::types::{WalletContentContainer, wallet_status_text}; +use crate::gui::views::{Content, Modal, TitlePanel, View}; +use crate::http::{ReleaseInfo, retrieve_release}; +use crate::settings::AppUpdate; +use crate::wallet::types::{ConnectionMethod, WalletTask}; +use crate::wallet::{Wallet, WalletList}; + +/// Wallets content. +pub struct WalletsContent { + /// List of wallets. + wallets: WalletList, + + /// Initial wallet creation [`Modal`] content. + add_wallet_modal_content: AddWalletModal, + /// Wallet opening [`Modal`] content. + open_wallet_content: OpenWalletModal, + /// Wallet settings [`Modal`] content. + wallet_settings_content: WalletSettingsModal, + /// Wallet selection [`Modal`] content. + wallet_selection_content: WalletListModal, + + /// Selected [`Wallet`] content. + wallet_content: WalletContent, + /// Wallet creation content. + creation_content: Option, + /// First-run onboarding content, shown while no wallets exist. + onboarding: Option, + + /// Settings content. + settings_content: Option, + + /// Result of update check + check_update: Bind, + /// Application update information. + update_info: (bool, Option), + /// Update changelog [`Modal`] content. + changelog_content: Option, +} + +/// Identifier for [`Modal`] to add the wallet. +const ADD_WALLET_MODAL: &'static str = "wallets_add_modal"; +/// Identifier for [`Modal`] to open the wallet. +const OPEN_WALLET_MODAL: &'static str = "wallets_open_wallet"; +/// Identifier for wallet settings [`Modal`]. +const WALLET_SETTINGS_MODAL: &'static str = "wallets_settings_modal"; +/// Identifier for wallet selection [`Modal`]. +const SELECT_WALLET_MODAL: &'static str = "wallets_select_modal"; + +impl Default for WalletsContent { + fn default() -> Self { + Self { + wallets: WalletList::default(), + wallet_selection_content: WalletListModal::new(None, None, true), + open_wallet_content: OpenWalletModal::new(), + add_wallet_modal_content: AddWalletModal::default(), + wallet_settings_content: WalletSettingsModal::new(ConnectionMethod::Integrated), + wallet_content: WalletContent::default(), + creation_content: None, + onboarding: None, + settings_content: None, + check_update: Bind::new(false), + update_info: (false, None), + changelog_content: None, + } + } +} + +impl ContentContainer for WalletsContent { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + ADD_WALLET_MODAL, + OPEN_WALLET_MODAL, + WALLET_SETTINGS_MODAL, + SELECT_WALLET_MODAL, + Self::DELETE_CONFIRMATION_MODAL, + ChangelogContent::MODAL_ID, + ] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + ADD_WALLET_MODAL => { + self.add_wallet_modal_content + .ui(ui, modal, cb, |name, pass| { + self.creation_content = + Some(WalletCreationContent::new(name.clone(), pass.clone())); + }); + } + OPEN_WALLET_MODAL => { + self.open_wallet_content.ui(ui, modal, cb, |pass| { + if let Some(w) = self.wallets.selected().as_ref() { + return match w.open(pass) { + Ok(_) => true, + Err(_) => false, + }; + } + true + }); + } + WALLET_SETTINGS_MODAL => { + self.wallet_settings_content.ui(ui, modal, cb, |conn| { + if let Some(w) = self.wallets.selected().as_ref() { + w.update_connection(&conn); + } + }); + } + SELECT_WALLET_MODAL => { + let mut w: Option = None; + let mut d: Option = None; + self.wallet_selection_content + .ui(ui, &mut self.wallets, |wallet, data| { + w = Some(wallet); + d = data; + }); + if let Some(wallet) = &w { + if !wallet.is_open() { + self.show_opening_modal(wallet, d, cb); + } else { + self.select_wallet(wallet, d, cb); + } + } + } + Self::DELETE_CONFIRMATION_MODAL => { + if let Some(w) = self.wallets.selected().as_ref() { + RecoverySettings::deletion_modal_ui(ui, w); + } + } + ChangelogContent::MODAL_ID => { + if let Some(c) = self.changelog_content.as_mut() { + c.ui(ui); + } + } + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + // Small repaint delay is needed for Android back navigation and account list opening. + let is_android = OperatingSystem::from_target_os() == OperatingSystem::Android; + let account_list_showing = self.wallet_content.account_content.show_list; + ui.ctx() + .request_repaint_after(Duration::from_millis(if account_list_showing { + 10 + } else if is_android { + 100 + } else { + 1000 + })); + + if let Some(data) = crate::consume_incoming_data() { + if !data.is_empty() { + self.on_data(ui, Some(data), cb); + } + } + + let showing_settings = self.showing_settings(); + let creating_wallet = self.creating_wallet(); + let showing_wallet = self.showing_wallet() && !creating_wallet && !showing_settings; + // First-run onboarding owns the surface while no wallets exist (and + // keeps it through its identity step, when the new wallet is already + // open but not yet selected). + let onboarding_active = !showing_wallet && self.onboarding_active(); + // Keep the Android status-bar icons readable against whatever paints the + // top strip. The GRIM screens (wallet list, app settings, wallet creation) + // leave the bright accent-yellow `title_panel_bg` showing under the status + // bar, which needs DARK icons. Onboarding, though, paints its OWN dark + // full-bleed surface (no title panel), so it needs theme-appropriate icons + // (white on the dark surface) — forcing dark there made them black-on-black. + // The Goblin wallet surface covers the inset itself and sets its per-tab flag. + if !showing_wallet && !onboarding_active { + crate::gui::theme::set_status_surface_yellow(true); + } else if onboarding_active { + crate::gui::theme::set_status_surface_yellow(false); + } + let dual_panel = is_dual_panel_mode(ui); + let content_width = ui.available_width(); + let list_hidden = showing_settings + || creating_wallet + || onboarding_active + || self.wallets.list().is_empty() + || (showing_wallet && (!dual_panel || !AppConfig::show_wallets_at_dual_panel())); + + // Show the title panel everywhere except the full-bleed Goblin wallet + // surface and onboarding (which carry their own headers). The wallet + // list keeps GRIM's title bar — it puts the app-settings gear within + // reach at the top-right and gives the list a proper header, painted in + // the Pay-screen accent yellow. + if !showing_wallet && !onboarding_active { + self.title_ui(ui, dual_panel, cb); + } + + egui::SidePanel::right("wallet_panel") + .resizable(false) + .exact_width(if list_hidden { + content_width + } else { + content_width - Content::SIDE_PANEL_WIDTH + }) + .frame(egui::Frame { + fill: Colors::fill_deep(), + ..Default::default() + }) + .show_animated_inside(ui, showing_wallet, |ui| { + // Show selected wallet content. + if let Some(w) = self.wallets.selected().as_ref() { + self.wallet_content.ui(ui, w, cb); + } + }); + + // "Switch wallet" in the goblin settings asks to return to the chooser + // without locking the current wallet (it stays open, so re-picking it is + // instant). Locking is a separate, explicit action. + if self.wallet_content.take_switch_request() { + self.wallets.select(None); + } + + // Show wallet list tabs. + let side_padding = View::TAB_ITEMS_PADDING + if View::is_desktop() { 0.0 } else { 4.0 }; + let tabs_margin = Margin { + left: (View::far_left_inset_margin(ui) + side_padding) as i8, + right: (View::far_right_inset_margin(ui) + side_padding) as i8, + top: View::TAB_ITEMS_PADDING as i8, + bottom: (View::get_bottom_inset() + View::TAB_ITEMS_PADDING) as i8, + }; + egui::TopBottomPanel::bottom("wallets_bottom_panel") + .frame(egui::Frame { + inner_margin: tabs_margin, + fill: Colors::fill(), + ..Default::default() + }) + .resizable(false) + .show_animated_inside(ui, !list_hidden, |ui| { + let rect = ui.available_rect_before_wrap(); + + // Setup spacing between tabs. + ui.style_mut().spacing.item_spacing = egui::vec2(View::TAB_ITEMS_PADDING, 0.0); + + ui.vertical_centered(|ui| { + let pressed = Modal::opened() == Some(ADD_WALLET_MODAL); + View::tab_button(ui, PLUS, None, Some(pressed), |_| { + self.show_add_wallet_modal(); + }); + }); + + // Draw content divider line. + let r = { + let mut r = rect.clone(); + r.min.y -= tabs_margin.top as f32; + r.min.x -= tabs_margin.left as f32; + r.max.x += tabs_margin.right as f32; + r + }; + View::line(ui, LinePosition::TOP, &r, Colors::stroke()); + }); + + egui::SidePanel::left("wallet_list_panel") + .exact_width(if dual_panel && showing_wallet { + Content::SIDE_PANEL_WIDTH + } else { + content_width + }) + .resizable(false) + .frame(egui::Frame { + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + View::content_padding()) as i8, + right: (View::far_right_inset_margin(ui) + View::content_padding()) as i8, + top: 3.0 as i8, + bottom: 4.0 as i8, + }, + fill: Colors::fill(), + ..Default::default() + }) + .show_animated_inside(ui, !list_hidden, |ui| { + // Show wallet list. + self.wallet_list_ui(ui, cb); + }); + + egui::CentralPanel::default() + .frame(egui::Frame { + inner_margin: if self.showing_settings() { + Margin { + left: (View::far_left_inset_margin(ui) + View::content_padding()) as i8, + right: (View::far_right_inset_margin(ui) + View::content_padding()) as i8, + top: 0, + bottom: 0, + } + } else { + Margin::default() + }, + fill: if self.showing_settings() { + Colors::fill_lite() + } else if onboarding_active { + Colors::fill() + } else { + Colors::fill_deep() + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + if self.showing_settings() { + if let Some(c) = &mut self.settings_content { + ScrollArea::vertical() + .id_salt("app_settings_wallets") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(1.0); + ui.vertical_centered(|ui| { + // Show application settings content. + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + c.ui(ui, cb); + }); + }); + }); + } + } else if self.creating_wallet() { + // Show wallet creation content. + let mut created_wallet: Option = None; + let creation = self.creation_content.as_mut().unwrap(); + let pass = creation.pass.clone(); + creation.content_ui(ui, cb, |wallet| { + created_wallet = Some(wallet); + }); + if let Some(w) = &created_wallet { + self.creation_content = None; + self.wallets.add(w.clone()); + if let Ok(_) = w.open(pass.clone()) { + self.select_wallet(w, None, cb); + } + } + } else if onboarding_active { + // First-run onboarding replaces the stock empty state; + // later wallets still use the list's add-wallet flow. + if self.onboarding.is_none() { + self.onboarding = Some(OnboardingContent::default()); + } + let ob = self.onboarding.as_mut().unwrap(); + if let Some(w) = ob.ui(ui, &mut self.wallets, cb) { + self.onboarding = None; + self.select_wallet(&w, None, cb); + } + } else { + return; + } + }); + } +} + +impl WalletsContent { + /// Identifier for wallet deletion confirmation [`Modal`]. + pub const DELETE_CONFIRMATION_MODAL: &'static str = "wallets_delete_confirmation_modal"; + + /// Called to navigate back, return `true` if action was not consumed. + pub fn on_back(&mut self, cb: &dyn PlatformCallbacks) -> bool { + if self.showing_settings() { + // Close settings. + self.settings_content = None; + return false; + } else if self.creating_wallet() { + // Close wallet creation. + let creation = self.creation_content.as_mut().unwrap(); + if creation.on_back() { + self.creation_content = None; + } + return false; + } else if self.showing_wallet() { + // Go back at stack or close wallet. + if self.wallet_content.can_back() { + self.wallet_content.back(cb); + } else { + self.wallets.select(None); + } + return false; + } + true + } + + /// Check if opened wallet is showing. + pub fn showing_wallet(&self) -> bool { + if let Some(w) = self.wallets.selected().as_ref() { + return w.is_open() + && !w.is_deleted() + && w.get_config().chain_type == AppConfig::chain_type(); + } + false + } + + /// Check if wallet is creating. + pub fn creating_wallet(&self) -> bool { + self.creation_content.is_some() + } + + /// Check if first-run onboarding owns the surface (no wallets yet, or + /// the onboarding identity step is still finishing). + pub fn onboarding_active(&self) -> bool { + !self.showing_settings() + && !self.creating_wallet() + && (self.onboarding.is_some() || self.wallets.list().is_empty()) + } + + /// Check if application settings are showing. + pub fn showing_settings(&self) -> bool { + self.settings_content.is_some() + } + + /// Check if the returning-user wallet list owns the surface (wallets + /// exist, none open, not mid-onboarding/creation/settings). On this + /// screen the node panel is demoted to an opt-in chip, not a column. + pub fn wallet_list_screen(&self) -> bool { + !self.showing_wallet() + && !self.onboarding_active() + && !self.showing_settings() + && !self.creating_wallet() + && !self.wallets.list().is_empty() + } + + /// Handle data from deeplink or opened file. + fn on_data(&mut self, ui: &mut egui::Ui, data: Option, cb: &dyn PlatformCallbacks) { + let wallets_size = self.wallets.list().len(); + if wallets_size == 0 { + return; + } + // Close network panel on single panel mode. + if !Content::is_dual_panel_mode(ui.ctx()) && Content::is_network_panel_open() { + Content::toggle_network_panel(); + } + // Pass data to single wallet or show wallets selection. + if wallets_size == 1 { + let w = self.wallets.list()[0].clone(); + if w.is_open() { + self.select_wallet(&w, data, cb); + } else { + self.show_opening_modal(&w, data, cb); + } + } else { + self.wallet_selection_content = WalletListModal::new(None, data, true); + Modal::new(SELECT_WALLET_MODAL) + .position(ModalPosition::Center) + .title(t!("network_settings.choose_wallet")) + .show(); + } + } + + /// Show initial wallet creation [`Modal`]. + pub fn show_add_wallet_modal(&mut self) { + self.add_wallet_modal_content = AddWalletModal::default(); + Modal::new(ADD_WALLET_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("wallets.add")) + .show(); + } + + /// Draw [`TitlePanel`] content. + fn title_ui(&mut self, ui: &mut egui::Ui, dual_panel: bool, cb: &dyn PlatformCallbacks) { + let showing_settings = self.showing_settings(); + let show_wallet = self.showing_wallet(); + let show_list = AppConfig::show_wallets_at_dual_panel(); + let creating_wallet = self.creating_wallet(); + + // Setup title. + let title_content = if show_wallet + && (!dual_panel || (dual_panel && !show_list)) + && !creating_wallet + && !showing_settings + { + let title = self.wallet_content.title().into(); + let subtitle = self.wallets.selected().unwrap().get_config().name; + let wallet_title_content = if self.wallet_content.settings_content.is_some() { + TitleContentType::Title(title) + } else { + TitleContentType::WithSubTitle(title, subtitle, false) + }; + TitleType::Single(wallet_title_content) + } else { + let title_text = if showing_settings { + t!("settings") + } else if creating_wallet { + t!("wallets.add") + } else { + t!("wallets.title") + } + .into(); + let dual_title = !showing_settings && !creating_wallet && show_wallet && dual_panel; + if dual_title { + let title = self.wallet_content.title().into(); + let subtitle = self.wallets.selected().unwrap().get_config().name; + let wallet_title_content = if self.wallet_content.settings_content.is_some() { + TitleContentType::Title(title) + } else { + TitleContentType::WithSubTitle(title, subtitle, false) + }; + TitleType::Dual(TitleContentType::Title(title_text), wallet_title_content) + } else { + TitleType::Single(TitleContentType::Title(title_text)) + } + }; + + // Draw title panel. + let mut show_settings = false; + let showing_settings = self.showing_settings(); + TitlePanel::new(Id::new("wallets_title_panel")).ui( + title_content, + |ui| { + if self.showing_settings() { + View::title_button_big(ui, ARROW_LEFT, |_| { + self.settings_content = None; + }); + } else if show_wallet && !dual_panel { + View::title_button_big(ui, ARROW_LEFT, |_| { + if self.wallet_content.can_back() { + self.wallet_content.back(cb); + } else { + self.wallets.select(None); + } + }); + } else if self.creating_wallet() { + let mut close = false; + if let Some(creation) = self.creation_content.as_mut() { + View::title_button_big(ui, ARROW_LEFT, |_| { + if creation.on_back() { + close = true; + } + }); + } + if close { + self.creation_content = None; + } + } else if show_wallet && dual_panel { + let list_icon = if show_list { SIDEBAR_SIMPLE } else { SUITCASE }; + View::title_button_big(ui, list_icon, |_| { + AppConfig::toggle_show_wallets_at_dual_panel(); + }); + } + }, + |ui| { + if !showing_settings { + View::title_button_big(ui, GEAR, |_| { + // Show application settings. + show_settings = true; + }); + } + }, + ui, + ); + if show_settings { + self.wallet_content.back(cb); + self.settings_content = Some(SettingsContent::default()); + } + } + + /// Draw list of wallets. + fn wallet_list_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + ScrollArea::vertical() + .id_salt("wallet_list_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show(ui, |ui| { + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + // The app-settings gear now lives in the restored title bar + // above; the list starts straight into the app logo. + ui.add_space(8.0); + + // Show application logo and name. + View::app_logo_name_version(ui); + ui.add_space(15.0); + + // Show result of update check. + if AppConfig::check_updates() { + if let Some(res) = self + .check_update + .read_or_request(|| async { retrieve_release().await }) + { + let checked = self.update_info.0; + if !checked { + self.update_info.0 = true; + match res { + Ok(info) => { + if info.is_update() { + AppConfig::save_update(Some(info)); + } else { + AppConfig::save_update(None); + } + self.update_info.1 = AppConfig::app_update(); + } + Err(_) => AppConfig::save_update(None), + } + } + } + // Show update information. + self.update_info_ui(ui); + } + + let list = self.wallets.list().clone(); + for w in list.iter() { + let id = w.get_config().id; + // Remove deleted. + if w.is_deleted() { + self.wallets.select(None); + self.wallets.remove(id); + ui.ctx().request_repaint(); + continue; + } + // Check if wallet reopen is needed. + if w.reopen_needed() && !w.is_open() { + w.set_reopen(false); + self.show_opening_modal(w, None, cb); + } + // Check if wallet is selected. + let current = if let Some(selected) = self.wallets.selected().as_ref() { + selected.get_config().id == id + } else { + false + }; + // Unselect wallet when opening or settings modal was closed. + if current && !w.is_open() && Modal::opened().is_none() { + self.wallets.select(None); + } + self.wallet_item_ui(ui, w, current, cb); + ui.add_space(6.0); + } + }); + }); + } + + /// Draw wallet list item. + fn wallet_item_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + current: bool, + cb: &dyn PlatformCallbacks, + ) { + let config = wallet.get_config(); + let can_open = !wallet.is_open() && !wallet.files_moving(); + + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(78.0); + let r = View::item_rounding(0, 1, false); + let bg = if current { + Colors::fill_deep() + } else { + Colors::fill() + }; + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + if can_open { + if !wallet.is_repairing() { + View::item_button( + ui, + View::item_rounding(0, 1, true), + GEAR_FINE, + None, + || { + self.select_wallet(wallet, None, cb); + let conn = wallet.get_current_connection(); + self.wallet_settings_content = WalletSettingsModal::new(conn); + // Show connection selection modal. + Modal::new(WALLET_SETTINGS_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("wallets.settings")) + .show(); + }, + ); + } + } else if !wallet.is_closing() { + // Show button to close opened wallet. + View::item_button( + ui, + View::item_rounding(0, 1, true), + LOCK_KEY, + None, + || { + wallet.close(); + }, + ); + } + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + // Show wallet name text. + let name_color = if current { + Colors::white_or_black(true) + } else { + Colors::title(false) + }; + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.add_space(1.0); + View::ellipsize_text(ui, config.name, 18.0, name_color); + }); + + // Show wallet status text. + let status_text = wallet_status_text(wallet); + View::ellipsize_text(ui, status_text, 15.0, Colors::text(false)); + ui.add_space(1.0); + + // Show wallet connection text. + let connection = wallet.get_current_connection(); + let conn_text = match connection { + ConnectionMethod::Integrated => { + format!("{} {}", COMPUTER_TOWER, t!("network.node")) + } + ConnectionMethod::External(_, url) => { + format!("{} {}", GLOBE_SIMPLE, url) + } + }; + View::ellipsize_text(ui, conn_text, 15.0, Colors::gray()); + ui.add_space(3.0); + }); + }, + ); + }, + ) + .response; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if res.hovered() && (can_open || !current) { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::fill_deep(); + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked { + if can_open { + // Show modal to open the wallet. + self.show_opening_modal(wallet, None, cb); + } else if !current { + // Select opened wallet. + self.select_wallet(wallet, None, cb); + } + } + } + + /// Draw update information content. + fn update_info_ui(&mut self, ui: &mut egui::Ui) { + if self.update_info.1.is_none() { + return; + } + let update = self.update_info.1.as_ref().unwrap(); + ui.add_space(-4.0); + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(78.0); + let r = View::item_rounding(0, 1, false); + ui.painter().rect( + rect, + r, + Colors::fill(), + View::item_stroke(), + StrokeKind::Outside, + ); + ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| { + // Show button to download the update. + let mut link_clicked = false; + View::item_button( + ui, + View::item_rounding(0, 1, true), + CLOUD_ARROW_DOWN, + None, + || { + link_clicked = true; + }, + ); + if link_clicked { + ui.ctx().open_url(OpenUrl { + url: update.url.clone(), + new_tab: true, + }); + } + // Show button to see update information. + View::item_button(ui, CornerRadius::default(), NOTEPAD, None, || { + self.changelog_content = Some(ChangelogContent::new(update.changelog.clone())); + let title = format!("Grim {}", update.version); + Modal::new(ChangelogContent::MODAL_ID) + .position(ModalPosition::Center) + .title(title) + .show(); + }); + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.add_space(1.0); + let update_text = "Update is available!"; + View::ellipsize_text(ui, update_text, 18.0, Colors::green()); + }); + + // Show version info. + let ver_text = if let Some(size) = update.size.as_ref() { + format!("{} {} ({} MB)", BOOKMARKS, update.version, size) + } else { + format!("{} {} > {}", BOOKMARKS, crate::VERSION, update.version) + }; + View::ellipsize_text(ui, ver_text, 15.0, Colors::text(false)); + ui.add_space(1.0); + + // Show update date. + let date_text = format!("{} {}", CALENDAR_CHECK, update.date); + View::ellipsize_text(ui, date_text, 15.0, Colors::gray()); + ui.add_space(3.0); + }); + }); + }); + ui.add_space(12.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(12.0); + } + + /// Show [`Modal`] to select and open wallet. + fn show_opening_modal(&mut self, w: &Wallet, data: Option, cb: &dyn PlatformCallbacks) { + self.select_wallet(w, data, cb); + self.open_wallet_content = OpenWalletModal::new(); + Modal::new(OPEN_WALLET_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("wallets.open")) + .show(); + } + + /// Select wallet to make some actions on it. + fn select_wallet(&mut self, w: &Wallet, data: Option, cb: &dyn PlatformCallbacks) { + self.wallet_content.back(cb); + if let Some(data) = data { + w.task(WalletTask::OpenMessage(data)); + } + self.wallets.select(Some(w.get_config().id)); + } +} + +/// Check if it's possible to show [`WalletsContent`] and [`WalletContent`] panels at same time. +fn is_dual_panel_mode(ui: &mut egui::Ui) -> bool { + let dual_panel_root = Content::is_dual_panel_mode(ui.ctx()); + let max_width = ui.available_width(); + dual_panel_root && max_width >= (Content::SIDE_PANEL_WIDTH * 2.0) + View::get_right_inset() +} diff --git a/src/gui/views/wallets/creation/content.rs b/src/gui/views/wallets/creation/content.rs new file mode 100644 index 00000000..57d5d469 --- /dev/null +++ b/src/gui/views/wallets/creation/content.rs @@ -0,0 +1,376 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Id, Margin, RichText, ScrollArea}; +use grin_util::ZeroingString; + +use crate::gui::Colors; +use crate::gui::icons::{CHECK, CLIPBOARD_TEXT, COPY, SCAN}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::{ContentContainer, LinePosition, ModalPosition, QrScanResult}; +use crate::gui::views::wallets::ConnectionSettings; +use crate::gui::views::wallets::creation::MnemonicSetup; +use crate::gui::views::wallets::creation::types::Step; +use crate::gui::views::{CameraScanContent, Content, Modal, View}; +use crate::node::Node; +use crate::wallet::types::PhraseMode; +use crate::wallet::{ExternalConnection, Wallet}; + +/// Wallet creation content. +pub struct WalletCreationContent { + /// Wallet name. + pub name: String, + /// Wallet password. + pub pass: ZeroingString, + + /// Wallet creation step. + step: Step, + + /// QR code scanning [`Modal`] content. + scan_modal_content: Option, + + /// Mnemonic phrase setup content. + mnemonic_setup: MnemonicSetup, + /// Network setup content. + network_setup: ConnectionSettings, + + /// Flag to check if an error occurred during wallet creation. + creation_error: Option, +} + +const QR_CODE_PHRASE_SCAN_MODAL: &'static str = "qr_code_rec_phrase_scan_modal"; + +impl ContentContainer for WalletCreationContent { + fn modal_ids(&self) -> Vec<&'static str> { + vec![QR_CODE_PHRASE_SCAN_MODAL] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + QR_CODE_PHRASE_SCAN_MODAL => { + if let Some(content) = self.scan_modal_content.as_mut() { + content.modal_ui(ui, cb, |result| match result { + QrScanResult::Text(text) => { + self.mnemonic_setup.mnemonic.import(&text); + Modal::close(); + } + QrScanResult::SeedQR(text) => { + self.mnemonic_setup.mnemonic.import(&text); + Modal::close(); + } + _ => {} + }); + } + } + + _ => {} + } + } + + fn container_ui(&mut self, _: &mut egui::Ui, _: &dyn PlatformCallbacks) {} +} + +impl WalletCreationContent { + /// Create new wallet creation content from name and password. + pub fn new(name: String, pass: ZeroingString) -> Self { + Self { + name, + pass, + step: Step::EnterMnemonic, + scan_modal_content: None, + mnemonic_setup: MnemonicSetup::default(), + network_setup: ConnectionSettings::default(), + creation_error: None, + } + } + + /// Draw wallet creation content. + pub fn content_ui( + &mut self, + ui: &mut egui::Ui, + cb: &dyn PlatformCallbacks, + on_create: impl FnMut(Wallet), + ) { + self.ui(ui, cb); + egui::TopBottomPanel::bottom("wallet_creation_step_panel") + .frame(egui::Frame { + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + View::TAB_ITEMS_PADDING) as i8, + right: (View::get_right_inset() + View::TAB_ITEMS_PADDING) as i8, + top: View::TAB_ITEMS_PADDING as i8, + bottom: (View::get_bottom_inset() + View::TAB_ITEMS_PADDING) as i8, + }, + fill: Colors::fill(), + ..Default::default() + }) + .show_inside(ui, |ui| { + // Draw divider line. + let rect = { + let mut r = ui.available_rect_before_wrap(); + r.min.y -= View::TAB_ITEMS_PADDING; + r.min.x -= View::far_left_inset_margin(ui) + View::TAB_ITEMS_PADDING; + r.max.x += View::get_right_inset() + View::TAB_ITEMS_PADDING; + r + }; + View::line(ui, LinePosition::TOP, &rect, Colors::item_stroke()); + // Show step control content. + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + self.step_control_ui(ui, on_create, cb); + }); + }); + + // Show wallet creation step content panel. + egui::CentralPanel::default() + .frame(egui::Frame { + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + 4.0) as i8, + right: (View::get_right_inset() + 4.0) as i8, + top: 3.0 as i8, + bottom: 4.0 as i8, + }, + fill: Colors::fill_lite(), + ..Default::default() + }) + .show_inside(ui, |ui| { + ScrollArea::vertical() + .id_salt(Id::from(format!( + "creation_step_scroll_{}", + self.step.name() + ))) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show(ui, |ui| { + let max_width = if self.step == Step::SetupConnection { + Content::SIDE_PANEL_WIDTH * 1.3 + } else { + Content::SIDE_PANEL_WIDTH * 2.0 + }; + View::max_width_ui(ui, max_width, |ui| { + self.step_content_ui(ui, cb); + }); + }); + }); + } + + /// Draw [`Step`] description and confirmation control. + fn step_control_ui( + &mut self, + ui: &mut egui::Ui, + on_create: impl FnOnce(Wallet), + cb: &dyn PlatformCallbacks, + ) { + let step = &self.step; + // Setup description and next step availability. + let (step_text, mut next) = match step { + Step::EnterMnemonic => { + let mode = &self.mnemonic_setup.mnemonic.mode(); + let (text, available) = match mode { + PhraseMode::Generate => (t!("wallets.create_phrase_desc"), true), + PhraseMode::Import => { + let available = !self.mnemonic_setup.mnemonic.has_empty_or_invalid(); + (t!("wallets.restore_phrase_desc"), available) + } + }; + (text, available) + } + Step::ConfirmMnemonic => { + let text = t!("wallets.restore_phrase_desc"); + let available = !self.mnemonic_setup.mnemonic.has_empty_or_invalid(); + (text, available) + } + Step::SetupConnection => (t!("wallets.setup_conn_desc"), self.creation_error.is_none()), + }; + + // Show step description or error. + let generate_step = step == &Step::EnterMnemonic + && self.mnemonic_setup.mnemonic.mode() == PhraseMode::Generate; + if (self.mnemonic_setup.mnemonic.valid() && self.creation_error.is_none()) || generate_step + { + ui.label(RichText::new(step_text).size(16.0).color(Colors::gray())); + ui.add_space(6.0); + } else { + next = false; + // Show error text. + if let Some(err) = &self.creation_error { + ui.add_space(10.0); + ui.label(RichText::new(err).size(16.0).color(Colors::red())); + ui.add_space(10.0); + } else { + ui.label( + RichText::new(t!("wallets.not_valid_phrase")) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(4.0); + }; + } + + // Setup spacing between buttons. + ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 0.0); + // Setup vertical padding inside button. + ui.style_mut().spacing.button_padding = egui::vec2(10.0, 7.0); + + match step { + Step::EnterMnemonic => { + ui.columns(2, |columns| { + // Show copy or paste button for mnemonic phrase step. + columns[0].vertical_centered_justified(|ui| { + match self.mnemonic_setup.mnemonic.mode() { + PhraseMode::Generate => { + let c_t = format!("{} {}", COPY, t!("copy")); + View::button(ui, c_t, Colors::white_or_black(false), || { + cb.copy_string_to_buffer( + self.mnemonic_setup.mnemonic.get_phrase(), + ); + }); + } + PhraseMode::Import => { + let p_t = format!("{} {}", CLIPBOARD_TEXT, t!("paste")); + View::button(ui, p_t, Colors::white_or_black(false), || { + let data = ZeroingString::from(cb.get_string_from_buffer()); + self.mnemonic_setup.mnemonic.import(&data); + }); + } + } + }); + // Show next step or QR code scan button. + columns[1].vertical_centered_justified(|ui| { + if next { + self.next_step_button_ui(ui, on_create); + } else { + let scan_text = format!("{} {}", SCAN, t!("scan")); + View::button(ui, scan_text, Colors::white_or_black(false), || { + self.scan_modal_content = Some(CameraScanContent::default()); + // Show QR code scan modal. + Modal::new(QR_CODE_PHRASE_SCAN_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("scan_qr")) + .closeable(false) + .show(); + cb.start_camera(); + }); + } + }); + }); + } + Step::ConfirmMnemonic => { + // Show next step or paste button. + if next { + self.next_step_button_ui(ui, on_create); + } else { + let paste_text = format!("{} {}", CLIPBOARD_TEXT, t!("paste")); + View::button(ui, paste_text, Colors::white_or_black(false), || { + let data = ZeroingString::from(cb.get_string_from_buffer()); + self.mnemonic_setup.mnemonic.import(&data); + }); + } + } + Step::SetupConnection => { + if next { + self.next_step_button_ui(ui, on_create); + ui.add_space(2.0); + } + } + } + } + + /// Draw button to go to next [`Step`]. + fn next_step_button_ui(&mut self, ui: &mut egui::Ui, on_create: impl FnOnce(Wallet)) { + // Setup button text. + let (next_text, text_color, bg_color) = if self.step == Step::SetupConnection { + ( + format!("{} {}", CHECK, t!("complete")), + Colors::title(true), + Colors::gold(), + ) + } else { + ( + t!("continue").into(), + Colors::green(), + Colors::white_or_black(false), + ) + }; + + // Show next step button. + View::colored_text_button_ui(ui, next_text.to_uppercase(), text_color, bg_color, |ui| { + self.step = match self.step { + Step::EnterMnemonic => { + if self.mnemonic_setup.mnemonic.mode() == PhraseMode::Generate { + Step::ConfirmMnemonic + } else { + Step::SetupConnection + } + } + Step::ConfirmMnemonic => Step::SetupConnection, + Step::SetupConnection => { + // Create wallet at last step. + match Wallet::create( + &self.name, + &self.pass, + &self.mnemonic_setup.mnemonic, + &self.network_setup.method, + ) { + Ok(w) => { + self.mnemonic_setup.reset(); + // Pass created wallet to callback. + (on_create)(w); + Step::EnterMnemonic + } + Err(e) => { + self.creation_error = Some(format!("{:?}", e)); + Step::SetupConnection + } + } + } + }; + + // Check external connections availability on connection setup. + if self.step == Step::SetupConnection { + ExternalConnection::check(None, ui.ctx()); + } + }); + } + + /// Draw wallet creation [`Step`] content. + fn step_content_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + match &self.step { + Step::EnterMnemonic => self.mnemonic_setup.enter_ui(ui, cb), + Step::ConfirmMnemonic => self.mnemonic_setup.confirm_ui(ui, cb), + Step::SetupConnection => { + // Redraw if node is running. + if Node::is_running() && !Content::is_dual_panel_mode(ui.ctx()) { + ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY); + } + self.network_setup.ui(ui, cb); + } + } + } + + /// Back to previous wallet creation [`Step`], return `true` to close creation. + pub fn on_back(&mut self) -> bool { + match &self.step { + Step::ConfirmMnemonic => { + self.step = Step::EnterMnemonic; + false + } + Step::SetupConnection => { + self.creation_error = None; + self.step = Step::EnterMnemonic; + false + } + _ => true, + } + } +} diff --git a/src/gui/views/wallets/creation/mnemonic.rs b/src/gui/views/wallets/creation/mnemonic.rs new file mode 100644 index 00000000..49b57c91 --- /dev/null +++ b/src/gui/views/wallets/creation/mnemonic.rs @@ -0,0 +1,323 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; + +use crate::gui::Colors; +use crate::gui::icons::PENCIL; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{Content, Modal, TextEdit, View}; +use crate::wallet::Mnemonic; +use crate::wallet::types::{PhraseMode, PhraseSize, PhraseWord}; + +/// Mnemonic phrase setup content. +pub struct MnemonicSetup { + /// Current mnemonic phrase. + pub mnemonic: Mnemonic, + + /// Current word number to edit at [`Modal`]. + word_index_edit: usize, + /// Entered word value for [`Modal`]. + word_edit: String, + /// Flag to check if entered word is valid at [`Modal`]. + valid_word_edit: bool, +} + +/// Identifier for word input [`Modal`]. +pub const WORD_INPUT_MODAL: &'static str = "word_input_modal"; + +impl Default for MnemonicSetup { + fn default() -> Self { + Self { + mnemonic: Mnemonic::default(), + word_index_edit: 0, + word_edit: String::from(""), + valid_word_edit: true, + } + } +} + +impl ContentContainer for MnemonicSetup { + fn modal_ids(&self) -> Vec<&'static str> { + vec![WORD_INPUT_MODAL] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + WORD_INPUT_MODAL => self.word_modal_ui(ui, modal, cb), + _ => {} + } + } + + fn container_ui(&mut self, _: &mut egui::Ui, _: &dyn PlatformCallbacks) {} +} + +impl MnemonicSetup { + /// Draw content for phrase enter step. + pub fn enter_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + self.ui(ui, cb); + ui.add_space(10.0); + + // Show mode setup. + let mut mode = self.mnemonic.mode(); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + let create_mode = PhraseMode::Generate; + let create_text = t!("create"); + View::radio_value(ui, &mut mode, create_mode, create_text); + }); + columns[1].vertical_centered(|ui| { + let import_mode = PhraseMode::Import; + let import_text = t!("wallets.recover"); + View::radio_value(ui, &mut mode, import_mode, import_text); + }); + }); + if mode != self.mnemonic.mode() { + self.mnemonic.set_mode(mode); + } + + ui.add_space(10.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.words_count")) + .size(16.0) + .color(Colors::gray()), + ); + }); + ui.add_space(6.0); + + // Show mnemonic phrase size setup. + let mut size = self.mnemonic.size(); + ui.columns(5, |columns| { + for (index, word) in PhraseSize::VALUES.iter().enumerate() { + columns[index].vertical_centered(|ui| { + let text = word.value().to_string(); + View::radio_value(ui, &mut size, word.clone(), text); + }); + } + }); + if size != self.mnemonic.size() { + self.mnemonic.set_size(size); + } + + ui.add_space(12.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show words setup. + self.word_list_ui(ui, self.mnemonic.mode() == PhraseMode::Import); + } + /// Draw content for phrase confirmation step. + pub fn confirm_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + self.ui(ui, cb); + + ui.add_space(4.0); + ui.vertical_centered(|ui| { + let text = format!("{}:", t!("wallets.recovery_phrase")); + ui.label(RichText::new(text).size(16.0).color(Colors::gray())); + }); + ui.add_space(4.0); + self.word_list_ui(ui, true); + } + + /// Draw grid of words for mnemonic phrase. + pub(crate) fn word_list_ui(&mut self, ui: &mut egui::Ui, edit: bool) { + ui.add_space(6.0); + ui.scope(|ui| { + // Setup spacing between columns. + ui.spacing_mut().item_spacing = egui::Vec2::new(6.0, 6.0); + + // Select list of words based on current mode and edit flag. + let words = self.mnemonic.words(edit); + + let mut word_number = 0; + let cols = list_columns_count(ui); + let _ = words + .chunks(cols) + .map(|chunk| { + let size = chunk.len(); + word_number += 1; + if size > 1 { + ui.columns(cols, |columns| { + columns[0].horizontal(|ui| { + let word = chunk.get(0).unwrap(); + self.word_item_ui(ui, word_number, word, edit); + }); + columns[1].horizontal(|ui| { + word_number += 1; + let word = chunk.get(1).unwrap(); + self.word_item_ui(ui, word_number, word, edit); + }); + if size > 2 { + columns[2].horizontal(|ui| { + word_number += 1; + let word = chunk.get(2).unwrap(); + self.word_item_ui(ui, word_number, word, edit); + }); + } + if size > 3 { + columns[3].horizontal(|ui| { + word_number += 1; + let word = chunk.get(3).unwrap(); + self.word_item_ui(ui, word_number, word, edit); + }); + } + }); + } else { + ui.columns(cols, |columns| { + columns[0].horizontal(|ui| { + let word = chunk.get(0).unwrap(); + self.word_item_ui(ui, word_number, word, edit); + }); + }); + } + }) + .collect::>(); + }); + ui.add_space(6.0); + } + + /// Draw word grid item. + fn word_item_ui(&mut self, ui: &mut egui::Ui, num: usize, word: &PhraseWord, edit: bool) { + let color = if !word.valid || (word.text.is_empty() && !self.mnemonic.valid()) { + Colors::red() + } else { + Colors::white_or_black(true) + }; + if edit { + ui.add_space(6.0); + View::button( + ui, + PENCIL.to_string(), + Colors::white_or_black(false), + || { + self.word_index_edit = num - 1; + self.word_edit = word.text.clone(); + self.valid_word_edit = word.valid; + // Show word edit modal. + Modal::new(WORD_INPUT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("wallets.recovery_phrase")) + .show(); + }, + ); + ui.label( + RichText::new(format!("#{} {}", num, word.text)) + .size(17.0) + .color(color), + ); + } else { + ui.add_space(12.0); + let text = format!("#{} {}", num, word.text); + ui.label(RichText::new(text).size(17.0).color(color)); + } + } + + /// Reset mnemonic phrase state to default values. + pub fn reset(&mut self) { + self.mnemonic = Mnemonic::default(); + } + + /// Draw word input [`Modal`] content. + fn word_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + let on_save = |c: &mut MnemonicSetup| { + // Insert word checking validity. + let word = &c.word_edit.trim().to_string(); + c.valid_word_edit = c.mnemonic.insert(c.word_index_edit, word); + if !c.valid_word_edit { + return; + } + // Close modal or go to next word to edit. + let next_word = c.mnemonic.get(c.word_index_edit + 1); + let close_modal = next_word.is_none() + || (!next_word.as_ref().unwrap().text.is_empty() && next_word.unwrap().valid); + if close_modal { + Modal::close(); + } else { + c.word_index_edit += 1; + c.word_edit = String::from(""); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.enter_word", "number" => self.word_index_edit + 1)) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw word value text edit. + let mut word_edit = TextEdit::new(Id::from(modal.id).with(self.word_index_edit)); + word_edit.ui(ui, &mut self.word_edit, cb); + if word_edit.enter_pressed { + on_save(self); + } + + // Show error when specified word is not valid. + if !self.valid_word_edit { + ui.add_space(12.0); + ui.label( + RichText::new(t!("wallets.not_valid_word")) + .size(17.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + // Show save button. + View::button(ui, t!("continue"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } +} + +/// Calculate word list columns count based on available ui width. +fn list_columns_count(ui: &mut egui::Ui) -> usize { + let w = ui.available_width(); + let min_panel_w = Content::SIDE_PANEL_WIDTH - 12.0; + let double_min_panel_w = min_panel_w * 2.0; + if w >= min_panel_w * 1.5 && w < double_min_panel_w { + 3 + } else if w >= double_min_panel_w { + 4 + } else { + 2 + } +} diff --git a/src/gui/views/wallets/creation/mod.rs b/src/gui/views/wallets/creation/mod.rs new file mode 100644 index 00000000..532df557 --- /dev/null +++ b/src/gui/views/wallets/creation/mod.rs @@ -0,0 +1,21 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +mod mnemonic; +pub use mnemonic::MnemonicSetup; + +mod content; +pub use content::WalletCreationContent; + +pub mod types; diff --git a/src/gui/views/wallets/creation/types.rs b/src/gui/views/wallets/creation/types.rs new file mode 100644 index 00000000..6098f131 --- /dev/null +++ b/src/gui/views/wallets/creation/types.rs @@ -0,0 +1,35 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +/// Wallet creation step. +#[derive(PartialEq, Clone)] +pub enum Step { + /// Mnemonic phrase input. + EnterMnemonic, + /// Mnemonic phrase confirmation. + ConfirmMnemonic, + /// Wallet connection setup. + SetupConnection, +} + +impl Step { + /// Short name representing creation step. + pub fn name(&self) -> String { + match *self { + Step::EnterMnemonic => "enter_phrase".to_owned(), + Step::ConfirmMnemonic => "confirm_phrase".to_owned(), + Step::SetupConnection => "setup_conn".to_owned(), + } + } +} diff --git a/src/gui/views/wallets/mod.rs b/src/gui/views/wallets/mod.rs new file mode 100644 index 00000000..5b0829d0 --- /dev/null +++ b/src/gui/views/wallets/mod.rs @@ -0,0 +1,22 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +pub(crate) mod creation; +pub mod modals; + +mod content; +pub use content::*; + +pub mod wallet; +use wallet::*; diff --git a/src/gui/views/wallets/modals/add.rs b/src/gui/views/wallets/modals/add.rs new file mode 100644 index 00000000..230d06b4 --- /dev/null +++ b/src/gui/views/wallets/modals/add.rs @@ -0,0 +1,118 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; +use grin_util::ZeroingString; + +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::{Modal, TextEdit, View}; + +/// Initial wallet creation [`Modal`] content. +pub struct AddWalletModal { + /// Wallet name. + pub name_edit: String, + /// Password to encrypt created wallet. + pub pass_edit: String, +} + +impl Default for AddWalletModal { + fn default() -> Self { + Self { + name_edit: t!("wallets.default_wallet").into(), + pass_edit: "".to_string(), + } + } +} + +impl AddWalletModal { + /// Draw creating wallet name/password input [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + cb: &dyn PlatformCallbacks, + mut on_input: impl FnMut(String, ZeroingString), + ) { + let mut on_next = |m: &mut AddWalletModal| { + let name = m.name_edit.clone(); + let pass = m.pass_edit.clone(); + if name.is_empty() || pass.is_empty() { + return; + } + Modal::close(); + on_input(name, ZeroingString::from(pass)); + }; + ui.vertical_centered(|ui| { + ui.add_space(6.0); + ui.label( + RichText::new(t!("wallets.name")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Show wallet name text edit. + let mut name_input = TextEdit::new(Id::from(modal.id).with("name")).focus(false); + name_input.ui(ui, &mut self.name_edit, cb); + + ui.add_space(8.0); + ui.label( + RichText::new(t!("wallets.pass")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Show wallet password text edit. + let mut pass_input = TextEdit::new(Id::from(modal.id).with("pass")) + .password() + .focus(Modal::first_draw()); + if name_input.enter_pressed { + pass_input.focus_request(); + } + pass_input.ui(ui, &mut self.pass_edit, cb); + if pass_input.enter_pressed { + on_next(self); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("continue"), Colors::white_or_black(false), || { + on_next(self); + }); + }); + }); + ui.add_space(6.0); + }); + } +} diff --git a/src/gui/views/wallets/modals/changelog.rs b/src/gui/views/wallets/modals/changelog.rs new file mode 100644 index 00000000..991c3aaf --- /dev/null +++ b/src/gui/views/wallets/modals/changelog.rs @@ -0,0 +1,141 @@ +// Copyright 2026 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Id, OpenUrl, RichText, ScrollArea}; + +use crate::gui::Colors; +use crate::gui::icons::{BRACKETS_CURLY, GITHUB_LOGO, TELEGRAM_LOGO}; +use crate::gui::views::{Modal, View}; + +/// Application release changelog content. +pub struct ChangelogContent { + /// Changelog text. + changelog: String, +} + +/// Endpoint for GitHub repository. +const GITHUB_URL: &'static str = "https://github.com/GetGrin/grim"; +/// Endpoint for Telegram releases channel. +const TELEGRAM_URL: &'static str = "https://t.me/grim_releases"; +/// Endpoint for git repository. +const GIT_URL: &'static str = "https://code.gri.mw/GUI/grim"; + +impl ChangelogContent { + /// Create new content instance. + pub fn new(changelog: String) -> Self { + Self { changelog } + } + + /// Identifier for [`Modal`]. + pub const MODAL_ID: &'static str = "release_changelog_modal"; + + /// Draw changelog [`Modal`] content. + pub fn ui(&mut self, ui: &mut egui::Ui) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("changelog")) + .size(16.0) + .color(Colors::gray()), + ); + }); + ui.add_space(6.0); + + // Show changelog text. + ui.vertical_centered(|ui| { + let scroll_id = Id::from("release_changelog"); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(3.0); + ScrollArea::vertical() + .id_salt(scroll_id) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .max_height(128.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(7.0); + let input_id = scroll_id.with("_input"); + egui::TextEdit::multiline(&mut self.changelog) + .id(input_id) + .font(egui::TextStyle::Small) + .desired_rows(5) + .interactive(false) + .desired_width(f32::INFINITY) + .show(ui); + ui.add_space(6.0); + }); + }); + + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(3, |columns| { + columns[0].vertical_centered_justified(|ui| { + // Draw button to open GitHub link. + let mut github_clicked = false; + View::button(ui, GITHUB_LOGO, Colors::white_or_black(false), || { + github_clicked = true; + }); + if github_clicked { + ui.ctx().open_url(OpenUrl { + url: GITHUB_URL.into(), + new_tab: true, + }); + } + }); + columns[1].vertical_centered_justified(|ui| { + // Draw button to open Telegram link. + let mut tg_clicked = false; + View::button(ui, TELEGRAM_LOGO, Colors::white_or_black(false), || { + tg_clicked = true; + }); + if tg_clicked { + ui.ctx().open_url(OpenUrl { + url: TELEGRAM_URL.into(), + new_tab: true, + }); + } + }); + columns[2].vertical_centered_justified(|ui| { + // Draw button to open repository link. + let mut git_clicked = false; + View::button(ui, BRACKETS_CURLY, Colors::white_or_black(false), || { + git_clicked = true; + }); + if git_clicked { + ui.ctx().open_url(OpenUrl { + url: GIT_URL.into(), + new_tab: true, + }); + } + }); + }); + + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Show button to close modal. + ui.vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + Modal::close(); + }); + }); + ui.add_space(6.0); + } +} diff --git a/src/gui/views/wallets/modals/list.rs b/src/gui/views/wallets/modals/list.rs new file mode 100644 index 00000000..725bacb4 --- /dev/null +++ b/src/gui/views/wallets/modals/list.rs @@ -0,0 +1,176 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Align, Layout, RichText, ScrollArea, StrokeKind}; + +use crate::gui::Colors; +use crate::gui::icons::{CHECK, COMPUTER_TOWER, FOLDER_OPEN, GLOBE_SIMPLE, PLUGS_CONNECTED}; +use crate::gui::views::wallets::wallet::types::wallet_status_text; +use crate::gui::views::{Modal, View}; +use crate::wallet::types::ConnectionMethod; +use crate::wallet::{Wallet, WalletList}; + +/// Wallet list [`Modal`] content +pub struct WalletListModal { + /// Selected wallet id. + selected_id: Option, + + /// Optional data to pass after wallet selection. + data: Option, + + /// Flag to check if wallet can be opened from the list. + can_open: bool, +} + +impl WalletListModal { + /// Create new content instance. + pub fn new(selected_id: Option, data: Option, can_open: bool) -> Self { + Self { + selected_id, + data, + can_open, + } + } + + /// Draw content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallets: &WalletList, + mut on_select: impl FnMut(Wallet, Option), + ) { + ui.add_space(4.0); + ScrollArea::vertical() + .max_height(373.0) + .id_salt("select_wallet_list_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([true; 2]) + .show(ui, |ui| { + ui.add_space(2.0); + ui.vertical_centered(|ui| { + let data = self.data.clone(); + for wallet in wallets.list() { + // Draw wallet list item. + self.wallet_item_ui(ui, wallet, || { + Modal::close(); + on_select(wallet.clone(), data.clone()); + }); + ui.add_space(5.0); + } + }); + }); + + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show button to close modal. + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + self.data = None; + Modal::close(); + }, + ); + }); + ui.add_space(6.0); + } + + /// Draw wallet list item with provided callback on select. + fn wallet_item_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, on_select: impl FnOnce()) { + let config = wallet.get_config(); + let id = config.id; + + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(78.0); + let rounding = View::item_rounding(0, 1, false); + ui.painter().rect( + rect, + rounding, + Colors::fill(), + View::hover_stroke(), + StrokeKind::Outside, + ); + + ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| { + if self.can_open { + // Show button to select or open closed wallet. + let icon = if wallet.is_open() { CHECK } else { FOLDER_OPEN }; + View::item_button(ui, View::item_rounding(0, 1, true), icon, None, || { + on_select(); + }); + } else { + // Draw button to select wallet. + if self.selected_id.unwrap_or(0) == id { + View::selected_item_check(ui); + } else { + View::item_button(ui, View::item_rounding(0, 1, true), CHECK, None, || { + on_select(); + }); + } + } + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + // Show wallet name text. + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.add_space(1.0); + View::ellipsize_text(ui, config.name, 18.0, Colors::title(false)); + }); + + // Show wallet connection text. + let connection = wallet.get_current_connection(); + let conn_text = match connection { + ConnectionMethod::Integrated => { + format!("{} {}", COMPUTER_TOWER, t!("network.node")) + } + ConnectionMethod::External(_, url) => format!("{} {}", GLOBE_SIMPLE, url), + }; + ui.label( + RichText::new(conn_text) + .size(15.0) + .color(Colors::text(false)), + ); + ui.add_space(1.0); + + // Show wallet API text or open status. + if self.can_open { + ui.label( + RichText::new(wallet_status_text(wallet)) + .size(15.0) + .color(Colors::gray()), + ); + } else { + let address = if let Some(port) = config.api_port { + format!("127.0.0.1:{}", port) + } else { + "-".to_string() + }; + let api_text = format!("{} {}", PLUGS_CONNECTED, address); + ui.label(RichText::new(api_text).size(15.0).color(Colors::gray())); + } + ui.add_space(3.0); + }); + }); + }); + } +} diff --git a/src/gui/views/wallets/modals/mod.rs b/src/gui/views/wallets/modals/mod.rs new file mode 100644 index 00000000..68eb04fe --- /dev/null +++ b/src/gui/views/wallets/modals/mod.rs @@ -0,0 +1,28 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +mod settings; +pub use settings::*; + +mod list; +pub use list::*; + +mod open; +pub use open::*; + +mod add; +pub use add::*; + +mod changelog; +pub use changelog::*; diff --git a/src/gui/views/wallets/modals/open.rs b/src/gui/views/wallets/modals/open.rs new file mode 100644 index 00000000..af3b0463 --- /dev/null +++ b/src/gui/views/wallets/modals/open.rs @@ -0,0 +1,121 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; +use grin_util::ZeroingString; + +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::{Modal, TextEdit, View}; + +/// Wallet opening [`Modal`] content. +pub struct OpenWalletModal { + /// Password to open wallet. + pass_edit: String, + /// Flag to check if wrong password was entered. + wrong_pass: bool, +} + +impl OpenWalletModal { + /// Create new content instance. + pub fn new() -> Self { + Self { + pass_edit: "".to_string(), + wrong_pass: false, + } + } + /// Draw [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + cb: &dyn PlatformCallbacks, + mut on_continue: impl FnMut(ZeroingString) -> bool, + ) { + // Callback for button to continue. + let mut on_continue = |m: &mut OpenWalletModal| { + let pass = m.pass_edit.clone(); + if pass.is_empty() { + return; + } + m.wrong_pass = !on_continue(ZeroingString::from(pass)); + if !m.wrong_pass { + m.pass_edit = "".to_string(); + Modal::close(); + } + }; + + ui.vertical_centered(|ui| { + ui.add_space(6.0); + ui.label( + RichText::new(t!("wallets.pass")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Show password input. + let mut pass_edit = TextEdit::new(Id::from(modal.id).with("pass_edit")).password(); + pass_edit.ui(ui, &mut self.pass_edit, cb); + if pass_edit.enter_pressed { + (on_continue)(self); + } + + // Show information when password is empty. + if self.pass_edit.is_empty() { + self.wrong_pass = false; + ui.add_space(10.0); + ui.label( + RichText::new(t!("wallets.pass_empty")) + .size(17.0) + .color(Colors::inactive_text()), + ); + } else if self.wrong_pass { + ui.add_space(10.0); + ui.label( + RichText::new(t!("wallets.wrong_pass")) + .size(17.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("continue"), Colors::white_or_black(false), || { + (on_continue)(self); + }); + }); + }); + ui.add_space(6.0); + }); + } +} diff --git a/src/gui/views/wallets/modals/settings.rs b/src/gui/views/wallets/modals/settings.rs new file mode 100644 index 00000000..88b72136 --- /dev/null +++ b/src/gui/views/wallets/modals/settings.rs @@ -0,0 +1,192 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{RichText, ScrollArea}; + +use crate::gui::Colors; +use crate::gui::icons::{PLUS_CIRCLE, TRASH}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::ConnectionsContent; +use crate::gui::views::network::modals::ExternalConnectionModal; +use crate::gui::views::types::ModalPosition; +use crate::gui::views::wallets::WalletsContent; +use crate::gui::views::{Modal, View}; +use crate::wallet::types::ConnectionMethod; +use crate::wallet::{ConnectionsConfig, ExternalConnection}; + +/// Wallet connection selection [`Modal`] content. +pub struct WalletSettingsModal { + /// Current connection method. + pub conn: ConnectionMethod, + + /// External connection creation content. + new_ext_conn_content: Option, +} + +impl WalletSettingsModal { + /// Create from provided wallet connection. + pub fn new(conn: ConnectionMethod) -> Self { + Self { + conn, + new_ext_conn_content: None, + } + } + + /// Draw [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + cb: &dyn PlatformCallbacks, + on_select: impl Fn(ConnectionMethod), + ) { + // Draw external connection creation content. + if let Some(ext_content) = self.new_ext_conn_content.as_mut() { + ext_content.ui(ui, cb, modal, |conn| { + on_select(ConnectionMethod::External(conn.id, conn.url)); + }); + return; + } + + // Check connections state on first draw. + if Modal::first_draw() { + ExternalConnection::check(None, ui.ctx()); + } + + ui.add_space(4.0); + + let ext_conn_list = ConnectionsConfig::ext_conn_list(); + ScrollArea::vertical() + .max_height(if ext_conn_list.len() < 4 { + 330.0 + } else { + 350.0 + }) + .id_salt("connections_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([true; 2]) + .show(ui, |ui| { + ui.add_space(2.0); + + // Show integrated node selection. + let cur_integrated = self.conn == ConnectionMethod::Integrated; + let bg = if cur_integrated { + Colors::fill() + } else { + Colors::fill_lite() + }; + ConnectionsContent::integrated_node_item_ui( + ui, + bg, + (!cur_integrated, || { + on_select(ConnectionMethod::Integrated); + Modal::close(); + }), + |ui| { + if cur_integrated { + View::selected_item_check(ui); + } + cur_integrated + }, + ); + + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.ext_conn")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + // Show button to add new external node connection. + let add_node_text = format!("{} {}", PLUS_CIRCLE, t!("wallets.add_node")); + View::button(ui, add_node_text, Colors::white_or_black(false), || { + self.new_ext_conn_content = Some(ExternalConnectionModal::new(None)); + }); + }); + ui.add_space(4.0); + + if !ext_conn_list.is_empty() { + ui.add_space(8.0); + } + for (i, c) in ext_conn_list.iter().enumerate() { + ui.horizontal_wrapped(|ui| { + let len = ext_conn_list.len(); + let is_current = match self.conn { + ConnectionMethod::External(id, _) => id == c.id, + _ => false, + }; + let bg = if is_current { + Colors::fill() + } else { + Colors::fill_lite() + }; + ConnectionsContent::ext_conn_item_ui( + ui, + bg, + c, + i, + len, + (!is_current, || { + on_select(ConnectionMethod::External(c.id, c.url.clone())); + Modal::close(); + }), + |ui| { + if is_current { + View::selected_item_check(ui); + } + }, + ); + }); + } + ui.add_space(4.0); + }); + + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + ui.vertical_centered(|ui| { + // Draw button to delete the wallet. + View::colored_text_button( + ui, + format!("{} {}", TRASH, t!("wallets.delete")), + Colors::red(), + Colors::white_or_black(false), + || { + Modal::new(WalletsContent::DELETE_CONFIRMATION_MODAL) + .position(ModalPosition::Center) + .title(t!("confirmation")) + .show(); + }, + ); + }); + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Show button to close modal. + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + ui.add_space(6.0); + } +} diff --git a/src/gui/views/wallets/wallet/account/content.rs b/src/gui/views/wallets/wallet/account/content.rs new file mode 100644 index 00000000..fe7bf9fe --- /dev/null +++ b/src/gui/views/wallets/wallet/account/content.rs @@ -0,0 +1,434 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Align, Layout, RichText, ScrollArea, StrokeKind}; +use grin_core::core::amount_to_hr_string; + +use crate::gui::Colors; +use crate::gui::icons::{CHECK, FOLDER_USER, PACKAGE, PATH, SCAN, SPINNER, USER_PLUS, USERS_THREE}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::{ModalPosition, QrScanResult}; +use crate::gui::views::wallets::wallet::account::create::CreateAccountContent; +use crate::gui::views::wallets::wallet::request::SendRequestContent; +use crate::gui::views::wallets::wallet::types::{GRIN, WalletContentContainer}; +use crate::gui::views::{CameraContent, CameraScanContent, Content, Modal, View}; +use crate::wallet::types::{WalletAccount, WalletTask}; +use crate::wallet::{Wallet, WalletConfig}; + +/// Wallet account panel content. +pub struct WalletAccountContent { + /// Flag to show account list content. + pub show_list: bool, + /// Account creation [`Modal`] content. + create_account_content: CreateAccountContent, + + /// QR code scan content. + qr_scan_content: Option, + /// QR code scan result + qr_scan_result: Option, + /// Send request creation [`Modal`] content. + send_content: Option, +} + +/// Account creation [`Modal`] identifier. +const CREATE_MODAL_ID: &'static str = "create_account_modal"; +/// Identifier for sending request creation [`Modal`]. +const SEND_MODAL_ID: &'static str = "account_send_request_modal"; + +impl WalletContentContainer for WalletAccountContent { + fn modal_ids(&self) -> Vec<&'static str> { + vec![CREATE_MODAL_ID, SEND_MODAL_ID] + } + + fn modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + match modal.id { + CREATE_MODAL_ID => self.create_account_content.ui(ui, wallet, modal, cb), + SEND_MODAL_ID => { + if let Some(c) = self.send_content.as_mut() { + c.ui(ui, wallet, modal, cb); + } + } + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + if self.qr_scan_showing() { + self.qr_scan_ui(ui, wallet, cb); + } else { + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + if self.show_list { + self.list_ui(ui, wallet); + } else { + // Show account content. + self.account_ui(ui, wallet, cb); + } + }); + } + } +} + +impl Default for WalletAccountContent { + fn default() -> Self { + Self { + show_list: false, + create_account_content: CreateAccountContent::default(), + qr_scan_content: None, + qr_scan_result: None, + send_content: None, + } + } +} + +const ACCOUNT_ITEM_HEIGHT: f32 = 75.0; + +impl WalletAccountContent { + /// Check if QR code scanner was opened. + pub fn qr_scan_showing(&self) -> bool { + self.qr_scan_content.is_some() || self.qr_scan_result.is_some() + } + + /// Close QR code scanner. + pub fn close_qr_scan(&mut self, cb: &dyn PlatformCallbacks) { + if !self.qr_scan_showing() { + return; + } + cb.stop_camera(); + self.qr_scan_content = None; + self.qr_scan_result = None; + } + + /// Check if it's possible to go back at navigation stack. + pub fn can_back(&self) -> bool { + self.qr_scan_showing() || self.show_list + } + + /// Navigate back on navigation stack. + pub fn back(&mut self, cb: &dyn PlatformCallbacks) { + if self.qr_scan_showing() { + self.close_qr_scan(cb); + } else if self.show_list { + self.show_list = false; + } + } + + /// Draw wallet account content. + fn account_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + // Check wallet data. + let data = wallet.get_data(); + if data.is_none() { + return; + } + let data = data.unwrap(); + + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(75.0); + + // Draw round background. + let rounding = View::item_rounding(0, 2, false); + ui.painter().rect( + rect, + rounding, + Colors::fill(), + View::item_stroke(), + StrokeKind::Outside, + ); + + ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| { + // Draw button to show QR code scanner. + View::item_button(ui, View::item_rounding(0, 2, true), SCAN, None, || { + self.qr_scan_content = Some(CameraContent::default()); + cb.start_camera(); + }); + + // Draw button to show list of accounts. + let accounts = wallet.accounts(); + let accounts_icon = if accounts.len() > 1 { + USERS_THREE + } else { + USER_PLUS + }; + let rounding = View::item_rounding(1, 3, true); + View::item_button(ui, rounding, accounts_icon, None, || { + if accounts.len() == 1 { + self.create_account_content = CreateAccountContent::default(); + Modal::new(CREATE_MODAL_ID) + .position(ModalPosition::CenterTop) + .title(t!("wallets.accounts")) + .show(); + } else { + self.show_list = true; + } + }); + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| { + ui.add_space(8.0); + ui.vertical(|ui| { + ui.add_space(3.0); + // Show spendable amount. + let amount = amount_to_hr_string(data.info.amount_currently_spendable, true); + let amount_text = format!("{} {}", amount, GRIN); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.add_space(1.0); + ui.label( + RichText::new(amount_text) + .size(18.0) + .color(Colors::white_or_black(true)), + ); + }); + ui.add_space(-2.0); + + // Show account label. + let account = wallet.get_config().account; + let default_acc_label = WalletConfig::DEFAULT_ACCOUNT_LABEL.to_string(); + let acc_label = if account == default_acc_label { + t!("wallets.default_account").into() + } else { + account.to_owned() + }; + let acc_text = format!("{} {}", FOLDER_USER, acc_label); + View::ellipsize_text(ui, acc_text, 15.0, Colors::text(false)); + + // Show confirmed height or sync progress. + let status_text = if wallet.message_opening() { + format!("{} {}", SPINNER, t!("wallets.loading")) + } else if !wallet.syncing() { + format!("{} {}", PACKAGE, data.info.last_confirmed_height) + } else { + let info_progress = wallet.info_sync_progress(); + if info_progress == 100 || info_progress == 0 { + format!("{} {}", SPINNER, t!("wallets.wallet_loading")) + } else { + if wallet.is_repairing() { + let rep_progress = wallet.repairing_progress(); + if rep_progress == 0 { + format!("{} {}", SPINNER, t!("wallets.wallet_checking")) + } else { + format!( + "{} {}: {}%", + SPINNER, + t!("wallets.wallet_checking"), + rep_progress + ) + } + } else { + format!( + "{} {}: {}%", + SPINNER, + t!("wallets.wallet_loading"), + info_progress + ) + } + } + }; + let animate = wallet.syncing() || wallet.message_opening(); + View::animate_text(ui, status_text, 15.0, Colors::gray(), animate); + }) + }); + }); + } + + /// Draw account list content. + fn list_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let accounts = wallet.accounts(); + let size = accounts.len(); + ScrollArea::vertical() + .id_salt("account_list_scroll") + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .max_height(411.0) + .auto_shrink([true; 2]) + .show_rows(ui, ACCOUNT_ITEM_HEIGHT, size, |ui, row_range| { + for index in row_range { + let acc = accounts.get(index).unwrap().clone(); + let current = wallet.get_config().account == acc.label; + account_item_ui(ui, &acc, current, index, size, || { + let _ = wallet.set_active_account(&acc.label); + self.show_list = false; + }); + if index == size - 1 { + ui.add_space(4.0); + } + } + }); + + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + // Show modal buttons. + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + self.show_list = false; + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.add"), Colors::white_or_black(false), || { + self.show_list = false; + self.create_account_content = CreateAccountContent::default(); + Modal::new(CREATE_MODAL_ID) + .position(ModalPosition::CenterTop) + .title(t!("wallets.accounts")) + .show(); + }); + }); + }); + ui.add_space(6.0); + } + + /// Draw QR code scanner content. + fn qr_scan_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH, |ui| { + if self.qr_scan_content.is_some() { + if let Some(result) = self.qr_scan_content.as_ref().unwrap().qr_scan_result() { + cb.stop_camera(); + self.qr_scan_content = None; + match result { + QrScanResult::Address(a) => { + if let Some(data) = wallet.get_data() { + if data.info.amount_currently_spendable > 0 { + let address = Some(a.to_string()); + self.send_content = Some(SendRequestContent::new(address)); + Modal::new(SEND_MODAL_ID) + .position(ModalPosition::CenterTop) + .title(t!("wallets.send")) + .show(); + } + } + } + QrScanResult::Slatepack(m) => { + wallet.task(WalletTask::OpenMessage(m)); + } + _ => { + self.qr_scan_result = Some(result); + } + } + } else { + // Draw QR code scan content. + self.qr_scan_content.as_mut().unwrap().ui(ui, cb); + ui.add_space(6.0); + ui.vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + self.close_qr_scan(cb); + }); + }); + } + } else if let Some(res) = &self.qr_scan_result.clone() { + CameraScanContent::result_ui( + ui, + res, + cb, + || { + self.qr_scan_result = None; + }, + || { + self.qr_scan_content = Some(CameraContent::default()); + cb.start_camera(); + }, + ); + } + ui.add_space(6.0); + }); + } +} + +/// Draw account item. +fn account_item_ui( + ui: &mut egui::Ui, + acc: &WalletAccount, + current: bool, + index: usize, + size: usize, + mut on_select: impl FnMut(), +) { + // Setup layout size. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(ACCOUNT_ITEM_HEIGHT); + + // Draw round background. + let bg_rect = rect.clone(); + let item_rounding = View::item_rounding(index, size, false); + ui.painter().rect( + bg_rect, + item_rounding, + Colors::fill(), + View::item_stroke(), + StrokeKind::Outside, + ); + + ui.vertical(|ui| { + ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| { + // Draw button to select account. + if current { + View::selected_item_check(ui); + } else { + let button_rounding = View::item_rounding(index, size, true); + View::item_button(ui, button_rounding, CHECK, None, || { + on_select(); + }); + } + + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| { + ui.add_space(8.0); + ui.vertical(|ui| { + ui.add_space(3.0); + // Show spendable amount. + let amount = amount_to_hr_string(acc.spendable_amount, true); + let amount_text = format!("{} {}", amount, GRIN); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.add_space(1.0); + ui.label( + RichText::new(amount_text) + .size(18.0) + .color(Colors::white_or_black(true)), + ); + }); + ui.add_space(-2.0); + + // Show account name. + let default_acc_label = WalletConfig::DEFAULT_ACCOUNT_LABEL.to_string(); + let acc_label = if acc.label == default_acc_label { + t!("wallets.default_account").into() + } else { + acc.label.to_owned() + }; + let acc_name = format!("{} {}", FOLDER_USER, acc_label); + View::ellipsize_text(ui, acc_name, 15.0, Colors::text(false)); + + // Show account BIP32 derivation path. + let acc_path = format!("{} {}", PATH, acc.path); + ui.label(RichText::new(acc_path).size(15.0).color(Colors::gray())); + ui.add_space(3.0); + }); + }); + }); + }); +} diff --git a/src/gui/views/wallets/wallet/account/create.rs b/src/gui/views/wallets/wallet/account/create.rs new file mode 100644 index 00000000..b209e1cf --- /dev/null +++ b/src/gui/views/wallets/wallet/account/create.rs @@ -0,0 +1,110 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; + +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::{Modal, TextEdit, View}; +use crate::wallet::Wallet; + +/// Account creation [`Modal`] content. +pub struct CreateAccountContent { + /// Account label value. + account_label_edit: String, + /// Flag to check if error occurred during account creation. + account_creation_error: bool, +} + +impl Default for CreateAccountContent { + fn default() -> Self { + Self { + account_label_edit: "".to_string(), + account_creation_error: false, + } + } +} + +impl CreateAccountContent { + /// Draw account creation [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + let on_create = |m: &mut CreateAccountContent| { + if m.account_label_edit.is_empty() { + return; + } + let label = &m.account_label_edit; + match wallet.create_account(label) { + Ok(_) => { + let _ = wallet.set_active_account(label); + Modal::close(); + } + Err(_) => m.account_creation_error = true, + }; + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.new_account_desc")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw account name edit. + let mut name_edit = TextEdit::new(Id::from(modal.id).with(wallet.get_config().id)); + name_edit.ui(ui, &mut self.account_label_edit, cb); + if name_edit.enter_pressed { + on_create(self); + } + + // Show error occurred during account creation. + if self.account_creation_error { + ui.add_space(12.0); + ui.label(RichText::new(t!("error")).size(17.0).color(Colors::red())); + } + ui.add_space(12.0); + }); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + // Show modal buttons. + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("create"), Colors::white_or_black(false), || { + on_create(self); + }); + }); + }); + ui.add_space(6.0); + } +} diff --git a/src/gui/views/wallets/wallet/account/mod.rs b/src/gui/views/wallets/wallet/account/mod.rs new file mode 100644 index 00000000..739622cd --- /dev/null +++ b/src/gui/views/wallets/wallet/account/mod.rs @@ -0,0 +1,18 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +mod content; +pub use content::*; + +mod create; diff --git a/src/gui/views/wallets/wallet/content.rs b/src/gui/views/wallets/wallet/content.rs new file mode 100644 index 00000000..58d51201 --- /dev/null +++ b/src/gui/views/wallets/wallet/content.rs @@ -0,0 +1,635 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{ + ARROWS_CLOCKWISE, FILE_ARROW_DOWN, FILE_ARROW_UP, FILE_TEXT, GEAR_FINE, POWER, STACK, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::{LinePosition, ModalPosition}; +use crate::gui::views::wallets::wallet::account::WalletAccountContent; +use crate::gui::views::wallets::wallet::message::MessageInputContent; +use crate::gui::views::wallets::wallet::proof::PaymentProofContent; +use crate::gui::views::wallets::wallet::request::{InvoiceRequestContent, SendRequestContent}; +use crate::gui::views::wallets::wallet::types::WalletContentContainer; +use crate::gui::views::wallets::wallet::{WalletSettingsContent, WalletTransactionsContent}; +use crate::gui::views::{Content, Modal, View}; +use crate::node::Node; +use crate::wallet::types::{ConnectionMethod, WalletData, WalletTask}; +use crate::wallet::{ExternalConnection, Wallet}; + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Id, Margin, RichText, ScrollArea}; +use grin_chain::SyncStatus; + +/// Wallet content. +pub struct WalletContent { + /// Transactions content. + pub txs_content: Option, + + /// Settings content. + pub settings_content: Option, + + /// Account panel content. + pub account_content: WalletAccountContent, + + /// Invoice request creation [`Modal`] content. + invoice_content: Option, + /// Send request creation [`Modal`] content. + send_content: Option, + + /// Goblin payment-app-style surface (primary UI). + goblin: crate::gui::views::goblin::GoblinWalletView, +} + +/// Identifier for invoice creation [`Modal`]. +const INVOICE_MODAL_ID: &'static str = "invoice_request_modal"; +/// Identifier for sending request creation [`Modal`]. +const SEND_MODAL_ID: &'static str = "send_request_modal"; + +impl WalletContentContainer for WalletContent { + fn modal_ids(&self) -> Vec<&'static str> { + vec![INVOICE_MODAL_ID, SEND_MODAL_ID] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, w: &Wallet, m: &Modal, cb: &dyn PlatformCallbacks) { + match m.id { + INVOICE_MODAL_ID => { + if self.invoice_content.is_none() { + self.invoice_content = Some(InvoiceRequestContent::default()); + } + self.invoice_content.as_mut().unwrap().ui(ui, w, m, cb); + } + SEND_MODAL_ID => { + if self.send_content.is_none() { + self.send_content = Some(SendRequestContent::new(None)); + } + self.send_content.as_mut().unwrap().ui(ui, w, m, cb); + } + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + // Goblin surface is the primary UI. Show a sync screen until data is + // ready, then hand the whole surface to the payment-app-style view. + let block_nav_goblin = self.block_navigation_on_sync(wallet); + if block_nav_goblin || wallet.get_data().is_none() { + egui::CentralPanel::default() + .frame(egui::Frame { + fill: Colors::fill(), + ..Default::default() + }) + .show_inside(ui, |ui| { + sync_ui(ui, wallet); + }); + self.handle_task_result(wallet); + return; + } + egui::CentralPanel::default() + .frame(egui::Frame { + fill: Colors::fill(), + ..Default::default() + }) + .show_inside(ui, |ui| { + self.goblin.ui(ui, wallet, cb); + self.handle_task_result(wallet); + }); + } +} + +impl WalletContent { + #[allow(dead_code)] + fn legacy_container_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + let dual_panel = Content::is_dual_panel_mode(ui.ctx()); + let show_wallets_dual = AppConfig::show_wallets_at_dual_panel(); + + let wallet_id = wallet.identifier(); + let data = wallet.get_data(); + let block_nav = self.block_navigation_on_sync(wallet); + + // Show wallet account panel not on settings tab when navigation is not blocked and QR code + // scanner is not showing and wallet data is not empty. + let mut show_account = + self.settings_content.is_none() && !block_nav && !wallet.sync_error() && data.is_some(); + if wallet.get_current_connection() == ConnectionMethod::Integrated && !Node::is_running() { + show_account = false; + } + + // Show wallet tabs. + let side_padding = View::TAB_ITEMS_PADDING + if View::is_desktop() { 0.0 } else { 4.0 }; + let tabs_margin = Margin { + left: (View::far_left_inset_margin(ui) + side_padding) as i8, + right: (View::get_right_inset() + side_padding) as i8, + top: View::TAB_ITEMS_PADDING as i8, + bottom: (View::get_bottom_inset() + View::TAB_ITEMS_PADDING) as i8, + }; + egui::TopBottomPanel::bottom("wallet_tabs") + .frame(egui::Frame { + inner_margin: tabs_margin, + fill: Colors::fill(), + ..Default::default() + }) + .show_animated_inside(ui, !block_nav, |ui| { + let r = ui.available_rect_before_wrap(); + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + self.tabs_ui(wallet, data, ui); + }); + let rect = { + let mut r = r.clone(); + r.min.x -= tabs_margin.left as f32; + r.min.y -= tabs_margin.top as f32; + r.max.x += tabs_margin.right as f32; + r.max.y += tabs_margin.bottom as f32; + r + }; + // Draw cover for content below opened panel. + if self.can_back() && show_account { + View::content_cover_ui(ui, rect, "wallet_tabs_content_cover", || { + self.back(cb); + }); + } else { + // Draw content divider line. + View::line(ui, LinePosition::TOP, &rect, Colors::stroke()); + } + }); + + // Close scanner or account list when account panel got hidden. + if !show_account { + if self.account_content.qr_scan_showing() { + self.account_content.close_qr_scan(cb); + } else { + self.account_content.show_list = false; + } + } + + // Flag to check if account panel is opened. + let top_panel_expanded = self.account_content.can_back(); + + // Show wallet account content. + if show_account { + egui::TopBottomPanel::top(Id::from("wallet_account").with(wallet.identifier())) + .frame(egui::Frame { + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + View::content_padding()) as i8, + right: (View::get_right_inset() + View::content_padding()) as i8, + top: View::content_padding() as i8, + bottom: 0.0 as i8, + }, + fill: if top_panel_expanded { + Colors::fill_lite() + } else { + Colors::TRANSPARENT + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + let rect = ui.available_rect_before_wrap(); + self.account_content.ui(ui, &wallet, cb); + // Draw content divider lines. + let r = { + let mut r = rect.clone(); + r.min.x -= View::content_padding() + View::far_left_inset_margin(ui); + r.min.y -= View::content_padding(); + r.max.x += View::content_padding() + View::get_right_inset(); + r + }; + if dual_panel && show_wallets_dual { + View::line(ui, LinePosition::LEFT, &r, Colors::item_stroke()); + } + }); + } + + // Show tab content. + egui::CentralPanel::default() + .frame(egui::Frame { + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + View::content_padding()) as i8, + right: (View::get_right_inset() + View::content_padding()) as i8, + top: 0.0 as i8, + bottom: 4.0 as i8, + }, + fill: if self.settings_content.is_some() { + Colors::fill_lite() + } else { + Colors::TRANSPARENT + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + let rect = ui.available_rect_before_wrap(); + let show_settings = self.settings_content.is_some(); + let show_txs = self.txs_content.is_some() && !top_panel_expanded; + let show_sync = (!show_settings || block_nav) && sync_ui(ui, &wallet); + if !show_sync { + if show_settings { + ui.add_space(3.0); + ScrollArea::vertical() + .id_salt(Id::from("wallet_tab_content_scroll").with(wallet_id)) + .auto_shrink([false; 2]) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + self.settings_content.as_mut().unwrap().ui(ui, &wallet, cb); + }); + }); + } else if show_txs { + self.txs_content.as_mut().unwrap().ui(ui, &wallet, cb); + } + + // Handle wallet task result. + self.handle_task_result(wallet); + } + let rect = { + let mut r = rect.clone(); + r.min.x -= View::far_left_inset_margin(ui) + View::content_padding(); + r.max.x += View::get_right_inset() + View::content_padding(); + r.max.y += 4.0; + r + }; + // Draw cover for content below opened panel. + if !show_sync && self.can_back() { + View::content_cover_ui(ui, rect, "wallet_panel_content_cover", || { + self.back(cb); + }); + } + // Draw content divider line. + if dual_panel && show_wallets_dual { + View::line(ui, LinePosition::LEFT, &rect, Colors::item_stroke()); + } + }); + } +} + +impl Default for WalletContent { + fn default() -> Self { + Self { + txs_content: Some(WalletTransactionsContent::new(None)), + settings_content: None, + account_content: WalletAccountContent::default(), + invoice_content: None, + send_content: None, + goblin: crate::gui::views::goblin::GoblinWalletView::default(), + } + } +} + +impl WalletContent { + /// Get title based on current navigation state. + pub fn title(&self) -> impl Into { + if self.account_content.qr_scan_showing() { + t!("scan_qr") + } else if self.account_content.show_list { + t!("wallets.accounts") + } else if self.settings_content.is_some() { + t!("wallets.settings") + } else { + t!("wallets.txs") + } + } + + /// Check if it's possible to go back at navigation stack. + pub fn can_back(&self) -> bool { + self.goblin.overlay_active() || self.account_content.can_back() + } + + /// Take the pending "switch wallet" request from the goblin settings, so the + /// host can deselect this wallet (return to the chooser) without locking it. + pub fn take_switch_request(&mut self) -> bool { + self.goblin.take_switch_request() + } + + /// Navigate back on navigation stack. Returns true if not consumed. + pub fn back(&mut self, cb: &dyn PlatformCallbacks) -> bool { + if self.goblin.overlay_active() { + return self.goblin.on_back(); + } + if self.account_content.can_back() { + self.account_content.back(cb); + return false; + } + self.goblin.on_back() + } + + /// Check when to block tabs navigation on sync progress. + fn block_navigation_on_sync(&self, wallet: &Wallet) -> bool { + let sync_error = wallet.sync_error(); + let integrated_node = wallet.get_current_connection() == ConnectionMethod::Integrated; + let integrated_node_ready = Node::get_sync_status() == Some(SyncStatus::NoSync); + let sync_after_opening = wallet.get_data().is_none() && !wallet.sync_error(); + // Block navigation if wallet is repairing and integrated node is not launching + // and if wallet is closing or syncing after opening when there is no data to show. + (wallet.is_repairing() && (integrated_node_ready || !integrated_node) && !sync_error) + || wallet.is_closing() + || (sync_after_opening && (!integrated_node || integrated_node_ready)) + } + + /// Draw tab buttons at the bottom of the screen. + fn tabs_ui(&mut self, wallet: &Wallet, data: Option, ui: &mut egui::Ui) { + ui.scope(|ui| { + // Setup spacing between tabs. + ui.style_mut().spacing.item_spacing = egui::vec2(View::TAB_ITEMS_PADDING, 0.0); + + let can_send = if let Some(data) = &data { + data.info.amount_currently_spendable > 0 + } else { + false + }; + + let tabs_amount = if can_send { 5 } else { 4 }; + ui.columns(tabs_amount, |columns| { + columns[0].vertical_centered_justified(|ui| { + let active = self.settings_content.is_none() && self.txs_content.is_some(); + View::tab_button(ui, STACK, None, Some(active), |_| { + self.txs_content = Some(WalletTransactionsContent::new(None)); + self.settings_content = None; + }); + }); + let active = if data.is_some() { Some(false) } else { None }; + columns[1].vertical_centered_justified(|ui| { + if wallet.invoice_creating() { + ui.add_space(4.0); + View::small_loading_spinner(ui); + } else { + let (icon, color) = (FILE_ARROW_DOWN, Some(Colors::green())); + View::tab_button(ui, icon, color, active, |_| { + if self.txs_content.is_none() { + self.txs_content = Some(WalletTransactionsContent::new(None)); + self.settings_content = None; + } + self.txs_content.as_mut().unwrap().message_content = None; + + self.invoice_content = Some(InvoiceRequestContent::default()); + Modal::new(INVOICE_MODAL_ID) + .position(ModalPosition::CenterTop) + .title(t!("wallets.receive")) + .show(); + }); + } + }); + columns[2].vertical_centered_justified(|ui| { + if wallet.message_opening() { + ui.add_space(4.0); + View::small_loading_spinner(ui); + } else { + let (icon, color) = (FILE_TEXT, Some(Colors::gold_dark())); + View::tab_button(ui, icon, color, active, |_| { + if self.txs_content.is_none() { + self.txs_content = Some(WalletTransactionsContent::new(None)); + self.settings_content = None; + } + self.txs_content.as_mut().unwrap().message_content = + Some(MessageInputContent::default()); + Modal::new(MessageInputContent::MODAL_ID) + .position(ModalPosition::Center) + .title(t!("wallets.messages")) + .show(); + }); + } + }); + if can_send { + columns[3].vertical_centered_justified(|ui| { + if wallet.send_creating() { + ui.add_space(4.0); + View::small_loading_spinner(ui); + } else { + let (icon, color) = (FILE_ARROW_UP, Some(Colors::red())); + View::tab_button(ui, icon, color, active, |_| { + if self.txs_content.is_none() { + self.txs_content = Some(WalletTransactionsContent::new(None)); + self.settings_content = None; + } + self.txs_content.as_mut().unwrap().message_content = None; + + self.send_content = Some(SendRequestContent::new(None)); + Modal::new(SEND_MODAL_ID) + .position(ModalPosition::CenterTop) + .title(t!("wallets.send")) + .show(); + }); + } + }); + } + columns[tabs_amount - 1].vertical_centered_justified(|ui| { + let active = self.settings_content.is_some(); + View::tab_button(ui, GEAR_FINE, None, Some(active), |ui| { + ExternalConnection::check(None, ui.ctx()); + self.txs_content = None; + self.settings_content = Some(WalletSettingsContent::default()); + }); + }); + }); + }); + } + + /// Handle wallet task result. + fn handle_task_result(&mut self, wallet: &Wallet) { + let res = wallet.consume_task_result(); + let data = wallet.get_data(); + if res.is_none() || data.is_none() { + return; + } + let data = data.unwrap(); + let (id, t) = res.unwrap(); + match Modal::opened() { + None => { + // Show transaction modal on wallet task result. + if let Some(id) = id { + let tx = data.tx_by_id(id); + if tx.is_some() { + self.txs_content = Some(WalletTransactionsContent::new(tx)); + self.settings_content = None; + } + } + } + Some(modal_id) => { + match modal_id { + SEND_MODAL_ID => { + match t { + WalletTask::CalculateFee(_, f) => { + // Setup calculated tx fee at modal. + if let Some(m) = self.send_content.as_mut() { + if m.max_calculating { + let a = data.info.amount_currently_spendable; + let max = if f > a { 0 } else { a - f }; + m.on_max_amount_calculated(max, f); + } else { + m.on_fee_calculated(f); + } + } + } + _ => {} + } + } + MessageInputContent::MODAL_ID => { + match t { + WalletTask::VerifyProof(p, res) => { + if let Some(res) = res { + // Update message content on validation error + // or when tx not belongs to current wallet. + if res.is_err() + || (!res.as_ref().unwrap().1 && !res.as_ref().unwrap().2) + { + if let Some(txs) = self.txs_content.as_mut() { + if let Some(m) = txs.message_content.as_mut() { + if let Ok(p) = serde_json::to_string_pretty(&p) { + let mut c = PaymentProofContent::new(Some(p)); + c.validation_result = Some(res); + m.proof_content = Some(c); + } + } + } + } else if let Ok((id, _, _)) = res { + let tx = data.tx_by_id(id); + if let Some(tx) = tx { + let mut tx_c = WalletTransactionsContent::new(Some(tx)); + if let Ok(p) = serde_json::to_string_pretty(&p) { + let proof_c = PaymentProofContent::new(Some(p)); + tx_c.tx_info_content + .as_mut() + .unwrap() + .proof_content = Some(proof_c); + self.txs_content = Some(tx_c); + self.settings_content = None; + } + } + } + } + } + _ => {} + } + } + _ => {} + } + } + } + } +} + +/// Draw content when wallet is syncing and not ready to use, returns `true` at this case. +fn sync_ui(ui: &mut egui::Ui, wallet: &Wallet) -> bool { + if wallet.is_repairing() && !wallet.sync_error() { + sync_progress_ui(ui, wallet); + return true; + } else if wallet.is_closing() || wallet.files_moving() { + sync_progress_ui(ui, wallet); + return true; + } else if wallet.get_current_connection() == ConnectionMethod::Integrated { + if !Node::is_running() || Node::is_stopping() { + View::center_content(ui, 108.0, |ui| { + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + let text = t!("wallets.enable_node", "settings" => GEAR_FINE); + ui.label( + RichText::new(text) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(8.0); + // Show button to enable integrated node at non-dual root panel mode + // or when network connections are not showing and node is not stopping + let dual_panel = Content::is_dual_panel_mode(ui.ctx()); + if (!dual_panel || AppConfig::show_connections_network_panel()) + && !Node::is_stopping() + { + let enable_text = format!("{} {}", POWER, t!("network.enable_node")); + View::action_button(ui, enable_text, || { + Node::start(); + }); + } + }); + }); + return true; + } else if wallet.sync_error() && Node::get_sync_status() == Some(SyncStatus::NoSync) { + sync_error_ui(ui, wallet); + return true; + } else if wallet.get_data().is_none() { + sync_progress_ui(ui, wallet); + return true; + } + } else if wallet.sync_error() { + sync_error_ui(ui, wallet); + return true; + } else if wallet.get_data().is_none() { + sync_progress_ui(ui, wallet); + return true; + } + false +} + +/// Draw wallet sync error content. +fn sync_error_ui(ui: &mut egui::Ui, wallet: &Wallet) { + View::center_content(ui, 108.0, |ui| { + let text = t!("wallets.wallet_loading_err", "settings" => GEAR_FINE); + ui.label( + RichText::new(text) + .size(16.0) + .color(Colors::inactive_text()), + ); + ui.add_space(8.0); + let retry_text = format!("{} {}", ARROWS_CLOCKWISE, t!("retry")); + View::action_button(ui, retry_text, || { + wallet.set_sync_error(false); + }); + }); +} + +/// Draw wallet sync progress content. +fn sync_progress_ui(ui: &mut egui::Ui, wallet: &Wallet) { + View::center_content(ui, 162.0, |ui| { + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + View::big_loading_spinner(ui); + ui.add_space(18.0); + // Setup sync progress text. + let text = { + let int_node = wallet.get_current_connection() == ConnectionMethod::Integrated; + let int_ready = Node::get_sync_status() == Some(SyncStatus::NoSync); + let info_progress = wallet.info_sync_progress(); + + if wallet.files_moving() { + t!("moving_files").into() + } else if wallet.is_closing() { + t!("wallets.wallet_closing").into() + } else if int_node && !int_ready { + t!("wallets.node_loading", "settings" => GEAR_FINE).into() + } else if wallet.is_repairing() { + let repair_progress = wallet.repairing_progress(); + if repair_progress == 0 { + t!("wallets.wallet_checking").into() + } else { + format!("{}: {}%", t!("wallets.wallet_checking"), repair_progress) + } + } else if info_progress != 100 { + if info_progress == 0 { + t!("wallets.wallet_loading").into() + } else { + format!("{}: {}%", t!("wallets.wallet_loading"), info_progress) + } + } else { + t!("wallets.tx_loading").into() + } + }; + ui.label( + RichText::new(text) + .size(16.0) + .color(Colors::inactive_text()), + ); + }); + }); +} diff --git a/src/gui/views/wallets/wallet/message.rs b/src/gui/views/wallets/wallet/message.rs new file mode 100644 index 00000000..61fc27ea --- /dev/null +++ b/src/gui/views/wallets/wallet/message.rs @@ -0,0 +1,258 @@ +// Copyright 2026 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Id, RichText, ScrollArea}; + +use crate::gui::Colors; +use crate::gui::icons::{BROOM, CLIPBOARD_TEXT, SCAN, SEAL_CHECK}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::wallets::wallet::proof::PaymentProofContent; +use crate::gui::views::{CameraContent, FilePickContent, FilePickContentType, Modal, View}; +use crate::wallet::Wallet; +use crate::wallet::types::WalletTask; + +pub struct MessageInputContent { + /// Slatepack input text. + message_edit: String, + /// Flag to check if error happened at Slatepack message parsing. + parse_error: bool, + /// Button to parse picked file content. + file_pick_button: FilePickContent, + + /// QR code scanner content. + scan_qr_content: Option, + + /// Payment proof input content. + pub proof_content: Option, +} + +/// Hint for Slatepack message input. +const SLATEPACK_MESSAGE_HINT: &'static str = "BEGINSLATEPACK.\n...\n...\n...\nENDSLATEPACK."; + +impl Default for MessageInputContent { + fn default() -> Self { + Self { + message_edit: "".to_string(), + parse_error: false, + file_pick_button: FilePickContent::new(FilePickContentType::Button( + t!("choose_file").into(), + )), + scan_qr_content: None, + proof_content: None, + } + } +} + +impl MessageInputContent { + /// Identifier for [`Modal`]. + pub const MODAL_ID: &'static str = "input_message_modal"; + + /// Draw [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + if let Some(scan_content) = self.scan_qr_content.as_mut() { + if let Some(result) = scan_content.qr_scan_result() { + cb.stop_camera(); + modal.enable_closing(); + self.scan_qr_content = None; + // Parse scan result. + self.on_message_input(result.text(), wallet); + } else { + scan_content.ui(ui, cb); + } + ui.add_space(8.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + // Show buttons to close modal or scanner. + ui.columns(2, |cols| { + cols[0].vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + cb.stop_camera(); + self.scan_qr_content = None; + Modal::close(); + }); + }); + cols[1].vertical_centered_justified(|ui| { + View::button(ui, t!("back"), Colors::white_or_black(false), || { + cb.stop_camera(); + self.scan_qr_content = None; + modal.enable_closing(); + }); + }); + }); + } else if let Some(proof_content) = self.proof_content.as_mut() { + proof_content.input_ui(ui, wallet, cb); + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Show button to close modal. + ui.vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + self.message_edit = "".to_string(); + self.parse_error = false; + Modal::close(); + }); + }); + } else { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let (text, color) = if self.parse_error { + (t!("wallets.parse_slatepack_err"), Colors::red()) + } else { + (t!("wallets.input_slatepack_desc"), Colors::gray()) + }; + ui.label(RichText::new(text).size(16.0).color(color)); + }); + ui.add_space(6.0); + + // Draw slatepack message content. + let message_before = self.message_edit.clone(); + ui.vertical_centered(|ui| { + let scroll_id = Id::from("message_input").with(wallet.identifier()); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(3.0); + ScrollArea::vertical() + .id_salt(scroll_id) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .max_height(128.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(7.0); + let input_id = scroll_id.with("_input"); + let resp = egui::TextEdit::multiline(&mut self.message_edit) + .id(input_id) + .font(egui::TextStyle::Small) + .desired_rows(5) + .interactive(true) + .hint_text(SLATEPACK_MESSAGE_HINT) + .desired_width(f32::INFINITY) + .show(ui) + .response; + if View::is_desktop() { + resp.request_focus(); + } + ui.add_space(6.0); + }); + }); + // Parse message on input change. + if message_before != self.message_edit { + self.on_message_input(self.message_edit.clone(), wallet); + } + + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + if self.parse_error { + // Draw button to clear message input. + let clear_text = format!("{} {}", BROOM, t!("clear")); + View::button(ui, clear_text, Colors::white_or_black(false), || { + self.message_edit = "".to_string(); + self.parse_error = false; + }); + } else { + // Draw button to scan Slatepack message QR code. + let scan_text = format!("{} {}", SCAN, t!("scan")); + View::button(ui, scan_text, Colors::white_or_black(false), || { + modal.disable_closing(); + self.scan_qr_content = Some(CameraContent::default()); + cb.start_camera(); + }); + } + }); + columns[1].vertical_centered_justified(|ui| { + // Draw paste button. + let paste_text = format!("{} {}", CLIPBOARD_TEXT, t!("paste")); + View::button(ui, paste_text, Colors::white_or_black(false), || { + self.on_message_input(cb.get_string_from_buffer(), wallet); + }); + }); + }); + + // Draw button to pick Slatepack message file. + ui.add_space(8.0); + ui.vertical_centered(|ui| { + let mut picked_data = None; + self.file_pick_button.ui(ui, cb, |data| { + picked_data = Some(data); + }); + if let Some(data) = picked_data { + self.on_message_input(data, wallet); + } + }); + + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + ui.vertical_centered(|ui| { + let proof_label = format!("{} {}", SEAL_CHECK, t!("wallets.payment_proof")); + View::colored_text_button( + ui, + proof_label, + Colors::gold_dark(), + Colors::white_or_black(false), + || { + Modal::set_title(t!("wallets.payment_proof")); + self.proof_content = Some(PaymentProofContent::new(None)); + }, + ); + }); + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Show button to close modal. + ui.vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + self.message_edit = "".to_string(); + Modal::close(); + }); + }); + } + ui.add_space(6.0); + } + + /// Parse Slatepack message on input change. + fn on_message_input(&mut self, text: String, wallet: &Wallet) { + self.parse_error = false; + self.message_edit = text.trim().to_string(); + if self.message_edit.is_empty() { + return; + } + if self.message_edit.starts_with("BEGINSLATEPACK.") + && self.message_edit.ends_with("ENDSLATEPACK.") + { + wallet.task(WalletTask::OpenMessage(self.message_edit.to_string())); + self.message_edit = "".to_string(); + Modal::close(); + } else { + self.parse_error = true; + } + } +} diff --git a/src/gui/views/wallets/wallet/mod.rs b/src/gui/views/wallets/wallet/mod.rs new file mode 100644 index 00000000..c338a0ce --- /dev/null +++ b/src/gui/views/wallets/wallet/mod.rs @@ -0,0 +1,29 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +pub mod types; + +mod settings; +pub use settings::*; + +mod txs; +pub use txs::*; + +mod content; +pub use content::WalletContent; + +mod account; +mod message; +mod proof; +mod request; diff --git a/src/gui/views/wallets/wallet/proof.rs b/src/gui/views/wallets/wallet/proof.rs new file mode 100644 index 00000000..7ded49fa --- /dev/null +++ b/src/gui/views/wallets/wallet/proof.rs @@ -0,0 +1,232 @@ +use egui::scroll_area::ScrollBarVisibility; +use egui::{Id, RichText, ScrollArea}; +use grin_util::ToHex; +use grin_wallet_libwallet::{Error, PaymentProof, TxLogEntryType}; + +use crate::gui::Colors; +use crate::gui::icons::{BROOM, CLIPBOARD_TEXT, COPY, FILE_TEXT, SEAL_CHECK}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::{FilePickContent, FilePickContentType, Modal, View}; +use crate::wallet::Wallet; +use crate::wallet::types::{WalletTask, WalletTx}; + +pub struct PaymentProofContent { + /// Payment proof text. + input_edit: String, + /// Button to pick payment proof file. + pick_button: FilePickContent, + /// Flag to check if an error occurred during proof parsing. + parse_error: bool, + /// Proof validation result. + pub validation_result: Option>, +} + +impl PaymentProofContent { + /// Create new content to share or validate payment proof. + pub fn new(proof_text: Option) -> Self { + Self { + input_edit: proof_text.unwrap_or("".to_string()), + pick_button: FilePickContent::new(FilePickContentType::Button(t!("file").into())), + parse_error: false, + validation_result: None, + } + } + + /// Draw transaction payment proof input. + pub fn input_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + if self.parse_error { + let label_text = t!("wallets.payment_proof_error"); + ui.label(RichText::new(label_text).size(16.0).color(Colors::red())); + } else if let Some(proof) = self.validation_result.as_ref() { + match proof { + Ok(_) => { + let label_text = t!("wallets.payment_proof_valid"); + ui.label(RichText::new(label_text).size(16.0).color(Colors::green())); + } + Err(e) => { + let error_text = t!("wallets.payment_proof_error"); + let label_text = format!("{} ({:?})", error_text, e); + ui.label(RichText::new(label_text).size(16.0).color(Colors::red())); + } + } + } else { + let desc_label = t!("wallets.payment_proof_desc"); + ui.label( + RichText::new(desc_label) + .size(16.0) + .color(Colors::inactive_text()), + ); + } + }); + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let scroll_id = Id::from("tx_info_payment_proof_input"); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(3.0); + ScrollArea::vertical() + .id_salt(scroll_id) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .max_height(128.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(7.0); + let input_id = scroll_id.with("edit"); + let proof_input_before = self.input_edit.clone(); + let resp = egui::TextEdit::multiline(&mut self.input_edit) + .id(input_id) + .font(egui::TextStyle::Small) + .desired_rows(5) + .interactive(!wallet.payment_proof_verifying()) + .desired_width(f32::INFINITY) + .show(ui) + .response; + if View::is_desktop() { + resp.request_focus(); + } + // Parse payment proof on input change. + if self.input_edit != proof_input_before { + self.on_proof_edit_change(wallet); + } + ui.add_space(6.0); + }); + }); + + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + if wallet.payment_proof_verifying() { + ui.vertical_centered(|ui| { + View::small_loading_spinner(ui); + }); + } else { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + if self.parse_error + || (self.validation_result.is_some() + && self.validation_result.as_ref().unwrap().is_err()) + { + // Draw button to clear message input. + let clear_text = format!("{} {}", BROOM, t!("clear")); + View::button(ui, clear_text, Colors::white_or_black(false), || { + self.input_edit = "".to_string(); + self.parse_error = false; + self.validation_result = None; + }); + } else { + // Draw button to paste proof text. + let paste_text = format!("{} {}", CLIPBOARD_TEXT, t!("paste")); + View::button(ui, paste_text, Colors::white_or_black(false), || { + self.input_edit = cb.get_string_from_buffer(); + self.on_proof_edit_change(wallet); + }); + } + }); + columns[1].vertical_centered_justified(|ui| { + let mut changed = false; + self.pick_button.ui(ui, cb, |data| { + self.input_edit = data.clone(); + changed = true; + }); + if changed { + self.on_proof_edit_change(wallet); + } + }); + }); + } + } + + /// Callback on payment proof input change. + fn on_proof_edit_change(&mut self, wallet: &Wallet) { + if wallet.payment_proof_verifying() { + return; + } + if self.input_edit.is_empty() { + self.parse_error = false; + return; + } + if let Ok(p) = serde_json::from_str::(self.input_edit.as_str()) { + wallet.task(WalletTask::VerifyProof(p, None)); + } else { + self.parse_error = true; + } + } + + /// Draw transaction payment proof content to share. + pub fn share_ui(&mut self, ui: &mut egui::Ui, tx: &WalletTx, cb: &dyn PlatformCallbacks) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let (desc_text, color) = if tx.data.tx_type == TxLogEntryType::TxReceived { + (t!("wallets.payment_proof_valid").into(), Colors::green()) + } else { + ( + format!("{}:", t!("wallets.payment_proof")), + Colors::inactive_text(), + ) + }; + let desc = format!("{} {}", SEAL_CHECK, desc_text); + ui.label(RichText::new(desc).size(16.0).color(color)); + }); + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let scroll_id = Id::from("tx_info_payment_proof_share"); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(3.0); + ScrollArea::vertical() + .id_salt(scroll_id) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .max_height(128.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(7.0); + let input_id = scroll_id.with("edit"); + egui::TextEdit::multiline(&mut self.input_edit) + .id(input_id) + .font(egui::TextStyle::Small) + .desired_rows(5) + .interactive(false) + .desired_width(f32::INFINITY) + .show(ui); + ui.add_space(6.0); + }); + }); + + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + // Draw copy button. + let copy_text = format!("{} {}", COPY, t!("copy")); + View::button(ui, copy_text, Colors::white_or_black(false), || { + cb.copy_string_to_buffer(self.input_edit.clone()); + Modal::close(); + }); + }); + columns[1].vertical_centered_justified(|ui| { + let share_text = format!("{} {}", FILE_TEXT, t!("share")); + View::colored_text_button( + ui, + share_text, + Colors::blue(), + Colors::white_or_black(false), + || { + let file_name = format!("{}.txt", tx.data.kernel_excess.unwrap().to_hex()); + let data = self.input_edit.as_bytes().to_vec(); + cb.share_data(file_name, data).unwrap_or_default(); + Modal::close(); + }, + ); + }); + }); + } +} diff --git a/src/gui/views/wallets/wallet/request/invoice.rs b/src/gui/views/wallets/wallet/request/invoice.rs new file mode 100644 index 00000000..5a1199fe --- /dev/null +++ b/src/gui/views/wallets/wallet/request/invoice.rs @@ -0,0 +1,138 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; +use grin_core::core::{amount_from_hr_string, amount_to_hr_string}; + +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::{Modal, TextEdit, View}; +use crate::wallet::Wallet; +use crate::wallet::types::WalletTask; + +/// Invoice request creation content. +pub struct InvoiceRequestContent { + /// Amount to receive. + amount_edit: String, +} + +impl Default for InvoiceRequestContent { + fn default() -> Self { + Self { + amount_edit: "".to_string(), + } + } +} + +impl InvoiceRequestContent { + /// Draw [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + // Setup callback on continue. + let on_continue = |m: &mut InvoiceRequestContent| { + if m.amount_edit.is_empty() { + return; + } + if let Ok(a) = amount_from_hr_string(m.amount_edit.as_str()) { + m.amount_edit = "".to_string(); + wallet.task(WalletTask::Receive(a)); + Modal::close(); + } + }; + + ui.add_space(6.0); + + // Draw amount input content. + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.enter_amount_receive")) + .size(17.0) + .color(Colors::gray()), + ); + }); + ui.add_space(8.0); + + // Draw request amount text input. + let amount_edit_before = self.amount_edit.clone(); + let mut amount_edit = TextEdit::new(Id::from(modal.id).with(wallet.get_config().id)) + .h_center() + .numeric(); + amount_edit.ui(ui, &mut self.amount_edit, cb); + if amount_edit.enter_pressed { + on_continue(self); + } + + // Check value if input was changed. + if amount_edit_before != self.amount_edit { + if !self.amount_edit.is_empty() { + self.amount_edit = self.amount_edit.trim().replace(",", "."); + match amount_from_hr_string(self.amount_edit.as_str()) { + Ok(amount) => { + if !self.amount_edit.contains(".") { + // To avoid input of several `0` before `.` and put `.` after first `0`. + if self.amount_edit.len() != 1 && self.amount_edit.starts_with("0") { + let amount_text = amount_to_hr_string(amount, true); + let amount_parts = amount_text.split(".").collect::>(); + self.amount_edit = format!("0.{}", amount_parts[0]); + amount_edit.cursor_to_end(self.amount_edit.len(), ui); + } + } else { + // Check input after `.`. + let parts = self.amount_edit.split(".").collect::>(); + if parts.len() == 2 + && (parts[1].len() > 9 || (amount == 0 && parts[1].len() > 8)) + { + self.amount_edit = amount_edit_before; + } + } + } + Err(_) => { + self.amount_edit = amount_edit_before; + } + } + } + } + + ui.add_space(12.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + self.amount_edit = "".to_string(); + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + // Button to create Slatepack message request. + View::button(ui, t!("continue"), Colors::white_or_black(false), || { + on_continue(self); + }); + }); + }); + ui.add_space(6.0); + } +} diff --git a/src/gui/views/wallets/wallet/request/mod.rs b/src/gui/views/wallets/wallet/request/mod.rs new file mode 100644 index 00000000..24914892 --- /dev/null +++ b/src/gui/views/wallets/wallet/request/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +mod invoice; +pub use invoice::*; + +mod send; +pub use send::*; diff --git a/src/gui/views/wallets/wallet/request/send.rs b/src/gui/views/wallets/wallet/request/send.rs new file mode 100644 index 00000000..56f9afba --- /dev/null +++ b/src/gui/views/wallets/wallet/request/send.rs @@ -0,0 +1,349 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; +use grin_core::core::{amount_from_hr_string, amount_to_hr_string}; +use grin_core::global::get_accept_fee_base; +use grin_wallet_libwallet::SlatepackAddress; + +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::{CameraContent, Modal, TextEdit, View}; +use crate::wallet::Wallet; +use crate::wallet::types::WalletTask; + +/// Content to create a request to send funds. +pub struct SendRequestContent { + /// Amount to send. + amount_edit: String, + /// Flag to check if maximum amount is calculating. + pub max_calculating: bool, + + /// Fee amount. + fee_edit: String, + + /// Receiver address. + address_edit: String, + /// Flag to check if entered address is incorrect. + address_error: bool, + + /// Address QR code scanner content. + address_scan_content: Option, +} + +impl SendRequestContent { + /// Create new content instance with optional receiver address. + pub fn new(addr: Option) -> Self { + Self { + amount_edit: "".to_string(), + max_calculating: false, + fee_edit: "".to_string(), + address_edit: addr.unwrap_or("".to_string()), + address_error: false, + address_scan_content: None, + } + } + + /// Setup fee amount. + pub fn on_fee_calculated(&mut self, fee: u64) { + self.fee_edit = amount_to_hr_string(fee, true); + } + + /// Setup maximum amount to send and fee. + pub fn on_max_amount_calculated(&mut self, amount: u64, fee: u64) { + self.max_calculating = false; + if amount == 0 { + self.amount_edit = "".to_string(); + self.fee_edit = "".to_string(); + } else { + self.amount_edit = amount_to_hr_string(amount, true); + self.fee_edit = amount_to_hr_string(fee, true); + } + } + + /// Draw [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + let data = wallet.get_data(); + if data.is_none() { + Modal::close(); + return; + } + let data = data.unwrap(); + + ui.add_space(6.0); + + // Draw QR code scanner content if requested. + if let Some(scanner) = self.address_scan_content.as_mut() { + let on_stop = || { + cb.stop_camera(); + modal.enable_closing(); + }; + + if let Some(result) = scanner.qr_scan_result() { + self.address_edit = result.text(); + on_stop(); + self.address_scan_content = None; + } else { + ui.add_space(6.0); + scanner.ui(ui, cb); + ui.add_space(6.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + // Show buttons to close modal or come back to sending input. + ui.columns(2, |cols| { + cols[0].vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + on_stop(); + self.close(); + }); + }); + cols[1].vertical_centered_justified(|ui| { + View::button(ui, t!("back"), Colors::white_or_black(false), || { + on_stop(); + self.address_scan_content = None; + }); + }); + }); + ui.add_space(6.0); + } + return; + } + + ui.vertical_centered(|ui| { + let amount = amount_to_hr_string(data.info.amount_currently_spendable, true); + let enter_text = t!("wallets.enter_amount_send","amount" => amount); + ui.label(RichText::new(enter_text).size(17.0).color(Colors::gray())); + }); + ui.add_space(8.0); + + // Draw amount text edit. + let amount_edit_id = Id::from(modal.id) + .with("amount") + .with(wallet.get_config().id); + let mut amount_edit = TextEdit::new(amount_edit_id) + .h_center() + .numeric() + .focus(Modal::first_draw()); + if self.max_calculating { + amount_edit = amount_edit.disable(); + } + let amount_edit_before = self.amount_edit.clone(); + + // Draw button to calculate maximum amount to send. + let mut calculate_max = false; + amount_edit.custom_buttons_ui(ui, &mut self.amount_edit, cb, |ui| { + if self.max_calculating { + ui.add_space(12.0); + View::loading_spinner(ui, 40.0); + ui.add_space(12.0); + } else { + View::button(ui, t!("max_short"), Colors::white_or_black(false), || { + calculate_max = true; + }); + ui.add_space(8.0); + } + }); + if calculate_max { + self.max_calculating = true; + let max = data.info.amount_currently_spendable; + self.amount_edit = amount_to_hr_string(max, true); + } + ui.add_space(8.0); + + // Check value if input was changed. + if amount_edit_before != self.amount_edit { + if !self.amount_edit.is_empty() { + // Trim text, replace `,` by `.` and parse amount. + self.amount_edit = self.amount_edit.trim().replace(",", "."); + match amount_from_hr_string(self.amount_edit.as_str()) { + Ok(mut amount) => { + if !self.amount_edit.contains(".") { + // To avoid input of several `0` before `.` and put `.` after first `0`. + if self.amount_edit.len() != 1 && self.amount_edit.starts_with("0") { + let amount_text = amount_to_hr_string(amount, true); + let amount_parts = amount_text.split(".").collect::>(); + self.amount_edit = format!("0.{}", amount_parts[0]); + amount = amount_from_hr_string(self.amount_edit.as_str()) + .unwrap_or_else(|_| amount); + amount_edit.cursor_to_end(self.amount_edit.len(), ui); + } + // Reset fee amount on `0`. + if amount == 0 { + self.fee_edit = "".to_string(); + } + } else { + // Check input after `.`. + let parts = self.amount_edit.split(".").collect::>(); + if parts.len() == 2 + && (parts[1].len() > 9 || (amount == 0 && parts[1].len() > 8)) + { + self.amount_edit = amount_edit_before.clone(); + } + } + // Do not input amount more than balance. + if amount != 0 && self.amount_edit != amount_edit_before { + let fee = amount_from_hr_string(self.fee_edit.as_str()).unwrap_or(0); + let max = data.info.amount_currently_spendable; + if amount > max || amount + fee > max { + self.max_calculating = true; + wallet.task(WalletTask::CalculateFee(max, 0)); + } else { + wallet.task(WalletTask::CalculateFee(amount, 0)); + } + } + } + Err(_) => { + self.amount_edit = amount_edit_before; + } + } + } else { + self.fee_edit = "".to_string(); + } + } + + // Show fee value. + ui.vertical_centered(|ui| { + let fee_label = t!( + "wallets.fee_base_desc", + "value" => format!(": {}", get_accept_fee_base()) + ); + ui.label(RichText::new(fee_label).size(17.0).color(Colors::gray())); + }); + ui.add_space(6.0); + let fee_edit_id = Id::from(modal.id).with("_fee").with(wallet.get_config().id); + let mut fee_edit = TextEdit::new(fee_edit_id).focus(false).h_center().disable(); + let mut loading_label = format!("{}...", t!("wallets.loading")); + fee_edit.ui( + ui, + if wallet.fee_calculating() { + &mut loading_label + } else { + &mut self.fee_edit + }, + cb, + ); + + ui.add_space(8.0); + + // Show address error or input description. + ui.vertical_centered(|ui| { + if self.address_error { + ui.label( + RichText::new(t!("transport.incorrect_addr_err")) + .size(17.0) + .color(Colors::red()), + ); + } else { + ui.label( + RichText::new(t!("transport.receiver_address")) + .size(17.0) + .color(Colors::gray()), + ); + } + }); + ui.add_space(6.0); + + // Show address text edit. + let addr_edit_before = self.address_edit.clone(); + let address_edit_id = Id::from(modal.id) + .with("_address") + .with(wallet.get_config().id); + let mut address_edit = TextEdit::new(address_edit_id) + .paste() + .focus(false) + .scan_qr(); + if amount_edit.enter_pressed { + address_edit.focus_request(); + } + address_edit.ui(ui, &mut self.address_edit, cb); + // Check if scan button was pressed. + if address_edit.scan_pressed { + modal.disable_closing(); + self.address_scan_content = Some(CameraContent::default()); + } + ui.add_space(12.0); + // Check value if input was changed. + if addr_edit_before != self.address_edit { + self.address_error = false; + } + // Continue on Enter press. + if address_edit.enter_pressed { + self.on_continue(wallet); + } + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + self.close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + // Button to create Slatepack message request. + if self.max_calculating || wallet.fee_calculating() { + ui.add_space(4.0); + View::small_loading_spinner(ui); + } else { + View::button(ui, t!("continue"), Colors::white_or_black(false), || { + self.on_continue(wallet); + }); + } + }); + }); + ui.add_space(6.0); + } + + /// Callback when Continue button was pressed. + fn on_continue(&mut self, wallet: &Wallet) { + if self.amount_edit.is_empty() { + return; + } + // Validate the recipient slatepack address. + let addr_str = self.address_edit.as_str(); + if let Ok(r) = SlatepackAddress::try_from(addr_str.trim()) { + if let Ok(a) = amount_from_hr_string(self.amount_edit.as_str()) { + wallet.task(WalletTask::Send(a, Some(r))); + Modal::close(); + } + } else if !addr_str.is_empty() { + self.address_error = true; + } else if let Ok(a) = amount_from_hr_string(self.amount_edit.as_str()) { + wallet.task(WalletTask::Send(a, None)); + Modal::close(); + } + } + + /// Close modal and clear data. + fn close(&mut self) { + self.amount_edit = "".to_string(); + self.address_edit = "".to_string(); + self.address_scan_content = None; + Modal::close(); + } +} diff --git a/src/gui/views/wallets/wallet/settings/common.rs b/src/gui/views/wallets/wallet/settings/common.rs new file mode 100644 index 00000000..a2ae1aff --- /dev/null +++ b/src/gui/views/wallets/wallet/settings/common.rs @@ -0,0 +1,581 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use eframe::emath::Align; +use eframe::epaint::{RectShape, StrokeKind}; +use egui::{CursorIcon, Id, Layout, RichText, Sense, UiBuilder}; + +use crate::gui::Colors; +use crate::gui::icons::{CLOCK_COUNTDOWN, FOLDER_USER, FOLDERS, PASSWORD}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::ModalPosition; +use crate::gui::views::wallets::wallet::types::WalletContentContainer; +use crate::gui::views::{FilePickContent, FilePickContentType, Modal, TextEdit, View}; +use crate::wallet::Wallet; + +/// Common wallet settings content. +pub struct CommonSettings { + /// Wallet name [`Modal`] value. + name_edit: String, + + /// Flag to check if wrong password was entered. + wrong_pass: bool, + /// Current wallet password [`Modal`] value. + old_pass_edit: String, + /// New wallet password [`Modal`] value. + new_pass_edit: String, + + /// Data path value value for [`Modal`]. + data_path_edit: String, + /// Button to pick directory for wallet data. + pick_data_dir: FilePickContent, + + /// Minimum confirmations number value. + min_confirmations_edit: String, +} + +/// Identifier for wallet name [`Modal`]. +const NAME_EDIT_MODAL: &'static str = "wallet_name_edit_modal"; +/// Identifier for wallet password [`Modal`]. +const PASS_EDIT_MODAL: &'static str = "wallet_pass_edit_modal"; +/// Identifier for wallet data path value [`Modal`]. +const DATA_PATH_MODAL: &'static str = "wallet_data_path"; +/// Identifier for minimum confirmations [`Modal`]. +const MIN_CONFIRMATIONS_EDIT_MODAL: &'static str = "wallet_min_conf_edit_modal"; + +impl WalletContentContainer for CommonSettings { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + NAME_EDIT_MODAL, + PASS_EDIT_MODAL, + DATA_PATH_MODAL, + MIN_CONFIRMATIONS_EDIT_MODAL, + ] + } + + fn modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + match modal.id { + NAME_EDIT_MODAL => self.name_modal_ui(ui, wallet, modal, cb), + PASS_EDIT_MODAL => self.pass_modal_ui(ui, wallet, modal, cb), + DATA_PATH_MODAL => self.data_path_modal_ui(ui, wallet, cb), + MIN_CONFIRMATIONS_EDIT_MODAL => self.min_conf_modal_ui(ui, wallet, modal, cb), + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + if View::is_desktop() { + ui.add_space(1.0); + } else { + ui.add_space(8.0); + } + ui.vertical_centered(|ui| { + let config = wallet.get_config(); + // Show wallet name. + self.name_ui(ui, config.name); + // Show data dir for desktop. + if View::is_desktop() { + ui.add_space(-4.0); + self.data_dir_ui(ui, wallet, cb); + } + ui.add_space(6.0); + ui.label( + RichText::new(t!("wallets.min_tx_conf_count")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + // Show minimum amount of confirmations value setup. + let min_conf_text = format!("{} {}", CLOCK_COUNTDOWN, config.min_confirmations); + View::button(ui, min_conf_text, Colors::white_or_black(false), || { + self.min_confirmations_edit = config.min_confirmations.to_string(); + // Show minimum amount of confirmations value modal. + Modal::new(MIN_CONFIRMATIONS_EDIT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("network_settings.change_value")) + .show(); + }); + + ui.add_space(8.0); + + // Ability to post wallet transactions with Dandelion. + View::checkbox( + ui, + wallet.can_use_dandelion(), + t!("wallets.use_dandelion"), + || { + wallet.update_use_dandelion(!wallet.can_use_dandelion()); + }, + ); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(4.0); + }); + } +} + +impl Default for CommonSettings { + fn default() -> Self { + Self { + name_edit: "".to_string(), + wrong_pass: false, + old_pass_edit: "".to_string(), + new_pass_edit: "".to_string(), + data_path_edit: "".to_string(), + pick_data_dir: FilePickContent::new(FilePickContentType::ItemButton( + View::item_rounding(1, 2, true), + )) + .no_parse() + .pick_folder(), + min_confirmations_edit: "".to_string(), + } + } +} + +impl CommonSettings { + /// Draw content to change wallet name and password. + fn name_ui(&mut self, ui: &mut egui::Ui, name: String) { + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(56.0); + let r = if View::is_desktop() { + View::item_rounding(0, 2, false) + } else { + View::item_rounding(0, 1, false) + }; + let bg = Colors::fill_lite(); + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + let r = if View::is_desktop() { + View::item_rounding(0, 2, true) + } else { + View::item_rounding(0, 1, true) + }; + View::item_button(ui, r, PASSWORD, None, || { + self.old_pass_edit = "".to_string(); + self.new_pass_edit = "".to_string(); + self.wrong_pass = false; + // Show wallet password modal. + Modal::new(PASS_EDIT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("wallets.wallet")) + .show(); + }); + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(4.0); + View::ellipsize_text(ui, name.clone(), 18.0, Colors::title(false)); + ui.add_space(1.0); + let desc = format!( + "{} {}", + FOLDER_USER, + t!("wallets.name").replace(":", "") + ); + ui.label(RichText::new(desc).size(15.0).color(Colors::gray())); + ui.add_space(8.0); + }); + }, + ); + }, + ) + .response; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if res.hovered() { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::fill(); + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked { + self.name_edit = name; + // Show wallet name modal. + Modal::new(NAME_EDIT_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("wallets.wallet")) + .show(); + } + } + + /// Draw wallet name [`Modal`] content. + fn name_modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + let on_save = |c: &mut CommonSettings| { + if !c.name_edit.is_empty() { + wallet.change_name(c.name_edit.clone()); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.name")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + // Show wallet name text edit. + let mut name_edit = TextEdit::new(Id::from(modal.id).with(wallet.get_config().id)); + name_edit.ui(ui, &mut self.name_edit, cb); + if name_edit.enter_pressed { + on_save(self); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw wallet pass [`Modal`] content. + fn pass_modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + let wallet_id = wallet.get_config().id; + let on_continue = |c: &mut CommonSettings| { + if c.new_pass_edit.is_empty() { + return; + } + let old_pass = c.old_pass_edit.clone(); + let new_pass = c.new_pass_edit.clone(); + match wallet.change_password(old_pass, new_pass) { + Ok(_) => { + // Clear password values. + c.old_pass_edit = "".to_string(); + c.new_pass_edit = "".to_string(); + // Close modal. + Modal::close(); + } + Err(_) => c.wrong_pass = true, + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.current_pass")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw old password text edit. + let pass_edit_id = Id::from(modal.id).with(wallet_id).with("old_pass"); + let mut pass_edit = TextEdit::new(pass_edit_id) + .password() + .focus(Modal::first_draw()); + pass_edit.ui(ui, &mut self.old_pass_edit, cb); + ui.add_space(8.0); + + ui.label( + RichText::new(t!("wallets.new_pass")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw new password text edit. + let new_pass_edit_id = Id::from(modal.id).with(wallet_id).with("new_pass"); + let mut new_pass_edit = TextEdit::new(new_pass_edit_id).password().focus(false); + if pass_edit.enter_pressed { + new_pass_edit.focus_request(); + } + new_pass_edit.ui(ui, &mut self.new_pass_edit, cb); + if new_pass_edit.enter_pressed { + on_continue(self); + } + + // Show information when password is empty. + if self.old_pass_edit.is_empty() || self.new_pass_edit.is_empty() { + ui.add_space(10.0); + ui.label( + RichText::new(t!("wallets.pass_empty")) + .size(17.0) + .color(Colors::inactive_text()), + ); + } else if self.wrong_pass { + ui.add_space(10.0); + ui.label( + RichText::new(t!("wallets.wrong_pass")) + .size(17.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("change"), Colors::white_or_black(false), || { + on_continue(self); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Draw content to change wallet data directory. + fn data_dir_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + // Draw round background. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(56.0); + let r = View::item_rounding(1, 2, false); + let bg = Colors::fill_lite(); + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + self.pick_data_dir.ui(ui, cb, |path| { + wallet.change_data_path(path); + }); + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout( + layout_size, + Layout::left_to_right(Align::Center), + |ui| { + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(4.0); + let path = wallet.get_config().data_path.unwrap_or_default(); + View::ellipsize_text(ui, path, 18.0, Colors::title(false)); + ui.add_space(1.0); + let desc = format!("{} {}", FOLDERS, t!("files_location")); + ui.label(RichText::new(desc).size(15.0).color(Colors::gray())); + ui.add_space(8.0); + }); + }, + ); + }, + ) + .response; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if res.hovered() { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::fill(); + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked { + self.data_path_edit = wallet.get_config().data_path.unwrap_or_default(); + // Show chain data path edit modal. + Modal::new(DATA_PATH_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("wallets.wallet")) + .show(); + } + } + + /// Draw data path input [`Modal`] content. + fn data_path_modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let on_save = |path: &String| { + wallet.change_data_path(path.clone()); + Modal::close(); + }; + ui.label( + RichText::new(format!("{}:", t!("files_location"))) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw chain data path text edit. + let mut edit = TextEdit::new(Id::from(DATA_PATH_MODAL)).paste(); + edit.ui(ui, &mut self.data_path_edit, cb); + if edit.enter_pressed { + on_save(&self.data_path_edit); + } + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(&self.data_path_edit); + }); + }); + }); + ui.add_space(6.0); + }); + }); + } + + /// Draw wallet name [`Modal`] content. + fn min_conf_modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + let on_save = |c: &mut CommonSettings| { + if let Ok(min_conf) = c.min_confirmations_edit.parse::() { + wallet.update_min_confirmations(min_conf); + Modal::close(); + } + }; + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.min_tx_conf_count")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Minimum amount of confirmations text edit. + let mut min_confirmations_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric(); + min_confirmations_edit.ui(ui, &mut self.min_confirmations_edit, cb); + if min_confirmations_edit.enter_pressed { + on_save(self); + } + + // Show error when specified value is not valid or reminder to restart enabled node. + if self.min_confirmations_edit.parse::().is_err() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("network_settings.not_valid_value")) + .size(17.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + // Close modal. + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { + on_save(self); + }); + }); + }); + ui.add_space(6.0); + }); + } +} diff --git a/src/gui/views/wallets/wallet/settings/connection.rs b/src/gui/views/wallets/wallet/settings/connection.rs new file mode 100644 index 00000000..a80c20f6 --- /dev/null +++ b/src/gui/views/wallets/wallet/settings/connection.rs @@ -0,0 +1,176 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::RichText; + +use crate::gui::Colors; +use crate::gui::icons::{GLOBE, PLUS_CIRCLE}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::network::ConnectionsContent; +use crate::gui::views::network::modals::ExternalConnectionModal; +use crate::gui::views::types::{ContentContainer, ModalPosition}; +use crate::gui::views::{Modal, View}; +use crate::node::Node; +use crate::wallet::types::ConnectionMethod; +use crate::wallet::{ConnectionsConfig, ExternalConnection}; + +/// Wallet connection settings content. +pub struct ConnectionSettings { + /// Selected connection method. + pub method: ConnectionMethod, + + /// External connection [`Modal`] content. + ext_conn_modal: ExternalConnectionModal, +} + +impl Default for ConnectionSettings { + fn default() -> Self { + let method = { + let ext_conn_list = ConnectionsConfig::ext_conn_list(); + if Node::is_running() || Node::is_starting() || ext_conn_list.is_empty() { + ConnectionMethod::Integrated + } else { + let ext_conn = ext_conn_list.get(0).unwrap(); + ConnectionMethod::External(ext_conn.id, ext_conn.url.clone()) + } + }; + Self { + method, + ext_conn_modal: ExternalConnectionModal::new(None), + } + } +} + +impl ContentContainer for ConnectionSettings { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ExternalConnectionModal::WALLET_ID] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { + match modal.id { + ExternalConnectionModal::WALLET_ID => { + self.ext_conn_modal.ui(ui, cb, modal, |_| {}); + } + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { + ui.add_space(2.0); + View::sub_title(ui, format!("{} {}", GLOBE, t!("wallets.conn_method"))); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + + ui.vertical_centered(|ui| { + ui.add_space(6.0); + // Show integrated node selection. + let cur_integrated = self.method == ConnectionMethod::Integrated; + let bg = if cur_integrated { + Colors::fill_deep() + } else { + Colors::fill_lite() + }; + ConnectionsContent::integrated_node_item_ui( + ui, + bg, + (!cur_integrated, || { + self.method = ConnectionMethod::Integrated; + }), + |ui| { + if cur_integrated { + View::selected_item_check(ui); + } + cur_integrated + }, + ); + + ui.add_space(8.0); + ui.label( + RichText::new(t!("wallets.ext_conn")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + // Show button to add new external node connection. + let add_node_text = format!("{} {}", PLUS_CIRCLE, t!("wallets.add_node")); + View::button(ui, add_node_text, Colors::white_or_black(false), || { + self.ext_conn_modal = ExternalConnectionModal::new(None); + Modal::new(ExternalConnectionModal::WALLET_ID) + .position(ModalPosition::CenterTop) + .title(t!("wallets.add_node")) + .show(); + }); + ui.add_space(4.0); + + // Check for removed active connection. + let cur_method = &self.method.clone(); + let mut ext_conn_list = ConnectionsConfig::ext_conn_list(); + let has_method = !ext_conn_list + .iter() + .filter(|c| match cur_method { + ConnectionMethod::Integrated => true, + ConnectionMethod::External(id, url) => id == &c.id || url == &c.url, + }) + .collect::>() + .is_empty(); + if !has_method { + match cur_method { + ConnectionMethod::External(id, url) => ext_conn_list.push(ExternalConnection { + id: *id, + url: url.clone(), + username: Some("grin".to_string()), + secret: None, + available: Some(true), + }), + _ => {} + } + } + + let len = ext_conn_list.len(); + if len != 0 { + ui.add_space(8.0); + for (i, c) in ext_conn_list.iter().enumerate() { + ui.horizontal_wrapped(|ui| { + // Draw external connection item. + let is_current = match cur_method { + ConnectionMethod::External(id, url) => id == &c.id || url == &c.url, + _ => false, + }; + let bg = if is_current { + Colors::fill() + } else { + Colors::fill_lite() + }; + ConnectionsContent::ext_conn_item_ui( + ui, + bg, + c, + i, + len, + (!is_current, || { + self.method = ConnectionMethod::External(c.id, c.url.clone()); + }), + |ui| { + if is_current { + View::selected_item_check(ui); + } + }, + ); + }); + } + } + }); + } +} diff --git a/src/gui/views/wallets/wallet/settings/content.rs b/src/gui/views/wallets/wallet/settings/content.rs new file mode 100644 index 00000000..29cc0ce4 --- /dev/null +++ b/src/gui/views/wallets/wallet/settings/content.rs @@ -0,0 +1,62 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::ContentContainer; +use crate::gui::views::wallets::wallet::types::WalletContentContainer; +use crate::gui::views::wallets::{CommonSettings, ConnectionSettings, RecoverySettings}; +use crate::wallet::Wallet; + +/// Wallet settings tab content. +pub struct WalletSettingsContent { + /// Common setup content. + common_setup: CommonSettings, + /// Connection setup content. + conn_setup: ConnectionSettings, + /// Recovery setup content. + recovery_setup: RecoverySettings, +} + +impl Default for WalletSettingsContent { + fn default() -> Self { + Self { + common_setup: CommonSettings::default(), + conn_setup: ConnectionSettings::default(), + recovery_setup: RecoverySettings::default(), + } + } +} + +impl WalletSettingsContent { + pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + // Show common wallet setup. + self.common_setup.ui(ui, wallet, cb); + + // Show wallet connections setup. + self.conn_setup.method = wallet.get_current_connection(); + let method = self.conn_setup.method.clone(); + self.conn_setup.ui(ui, cb); + if method != self.conn_setup.method { + wallet.update_connection(&self.conn_setup.method); + // Reopen wallet if connection changed. + if !wallet.reopen_needed() { + wallet.set_reopen(true); + wallet.close(); + } + } + + // Show wallet recovery setup. + self.recovery_setup.ui(ui, wallet, cb); + } +} diff --git a/src/gui/views/wallets/wallet/settings/mod.rs b/src/gui/views/wallets/wallet/settings/mod.rs new file mode 100644 index 00000000..433323a2 --- /dev/null +++ b/src/gui/views/wallets/wallet/settings/mod.rs @@ -0,0 +1,25 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +mod content; +pub use content::*; + +mod connection; +pub use connection::ConnectionSettings; + +mod common; +pub use common::CommonSettings; + +mod recovery; +pub use recovery::RecoverySettings; diff --git a/src/gui/views/wallets/wallet/settings/recovery.rs b/src/gui/views/wallets/wallet/settings/recovery.rs new file mode 100644 index 00000000..9a229212 --- /dev/null +++ b/src/gui/views/wallets/wallet/settings/recovery.rs @@ -0,0 +1,332 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::{Id, RichText}; +use grin_chain::SyncStatus; +use grin_util::ZeroingString; + +use crate::gui::Colors; +use crate::gui::icons::{EYE, KEY, LIFEBUOY, STETHOSCOPE, TRASH}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::ModalPosition; +use crate::gui::views::wallets::wallet::types::WalletContentContainer; +use crate::gui::views::{Modal, TextEdit, View}; +use crate::node::Node; +use crate::wallet::Wallet; +use crate::wallet::types::ConnectionMethod; + +/// Wallet recovery settings content. +pub struct RecoverySettings { + /// Wallet password [`Modal`] value. + pass_edit: String, + /// Flag to check if wrong password was entered. + wrong_pass: bool, + + /// Recovery phrase value. + recovery_phrase: Option, +} + +/// Identifier for recovery phrase [`Modal`]. +const RECOVERY_PHRASE_MODAL: &'static str = "recovery_phrase_modal"; +/// Identifier to confirm wallet deletion [`Modal`]. +const DELETE_CONFIRMATION_MODAL: &'static str = "delete_wallet_confirmation_modal"; + +impl WalletContentContainer for RecoverySettings { + fn modal_ids(&self) -> Vec<&'static str> { + vec![RECOVERY_PHRASE_MODAL, DELETE_CONFIRMATION_MODAL] + } + + fn modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + match modal.id { + RECOVERY_PHRASE_MODAL => { + self.recovery_phrase_modal_ui(ui, wallet, modal, cb); + } + DELETE_CONFIRMATION_MODAL => { + Self::deletion_modal_ui(ui, wallet); + } + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, _: &dyn PlatformCallbacks) { + ui.add_space(10.0); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + View::sub_title(ui, format!("{} {}", KEY, t!("wallets.recovery"))); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(4.0); + + ui.vertical_centered(|ui| { + let integrated_node = wallet.get_current_connection() == ConnectionMethod::Integrated; + let integrated_node_ready = Node::get_sync_status() == Some(SyncStatus::NoSync); + if wallet.sync_error() || (integrated_node && !integrated_node_ready) { + ui.add_space(2.0); + ui.label( + RichText::new(t!("wallets.repair_unavailable")) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(2.0); + } else if !wallet.is_repairing() { + ui.add_space(6.0); + + // Draw button to repair the wallet. + let repair_text = format!("{} {}", STETHOSCOPE, t!("wallets.repair_wallet")); + View::action_button(ui, repair_text, || { + wallet.repair(); + }); + + ui.add_space(6.0); + ui.label( + RichText::new(t!("wallets.repair_desc")) + .size(16.0) + .color(Colors::inactive_text()), + ); + } + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + // Draw button to restore the wallet. + ui.add_space(4.0); + View::colored_text_button( + ui, + format!("{} {}", LIFEBUOY, t!("wallets.recover")), + Colors::green(), + Colors::white_or_black(false), + || { + wallet.delete_db(); + }, + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("wallets.restore_wallet_desc")) + .size(16.0) + .color(Colors::inactive_text()), + ); + + ui.add_space(6.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + + let recovery_text = format!("{}:", t!("wallets.recovery_phrase")); + ui.label( + RichText::new(recovery_text) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + + // Draw button to show recovery phrase. + let show_text = format!("{} {}", EYE, t!("show")); + View::button(ui, show_text, Colors::white_or_black(false), || { + self.show_recovery_phrase_modal(); + }); + + ui.add_space(12.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(6.0); + ui.label( + RichText::new(t!("wallets.delete_desc")) + .size(16.0) + .color(Colors::red()), + ); + ui.add_space(6.0); + + // Draw button to delete the wallet. + View::colored_text_button( + ui, + format!("{} {}", TRASH, t!("wallets.delete")), + Colors::red(), + Colors::white_or_black(false), + || { + Modal::new(DELETE_CONFIRMATION_MODAL) + .position(ModalPosition::Center) + .title(t!("confirmation")) + .show(); + }, + ); + ui.add_space(8.0); + }); + } +} + +impl Default for RecoverySettings { + fn default() -> Self { + Self { + wrong_pass: false, + pass_edit: "".to_string(), + recovery_phrase: None, + } + } +} + +impl RecoverySettings { + /// Show recovery phrase [`Modal`]. + fn show_recovery_phrase_modal(&mut self) { + // Setup modal values. + self.pass_edit = "".to_string(); + self.wrong_pass = false; + self.recovery_phrase = None; + // Show recovery phrase modal. + Modal::new(RECOVERY_PHRASE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("wallets.recovery_phrase")) + .show(); + } + + /// Draw recovery phrase [`Modal`] content. + fn recovery_phrase_modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ) { + let on_next = |c: &mut RecoverySettings| match wallet.get_recovery(c.pass_edit.clone()) { + Ok(phrase) => { + c.wrong_pass = false; + c.recovery_phrase = Some(phrase); + } + Err(_) => { + c.wrong_pass = true; + } + }; + + ui.add_space(6.0); + if self.recovery_phrase.is_some() { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(self.recovery_phrase.clone().unwrap().to_string()) + .size(17.0) + .color(Colors::white_or_black(true)), + ); + }); + ui.add_space(10.0); + ui.vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + self.recovery_phrase = None; + Modal::close(); + }); + }); + } else { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.pass")) + .size(17.0) + .color(Colors::gray()), + ); + ui.add_space(8.0); + + // Draw current wallet password text edit. + let pass_edit_id = Id::from(modal.id).with(wallet.get_config().id); + let mut pass_edit = TextEdit::new(pass_edit_id).password(); + pass_edit.ui(ui, &mut self.pass_edit, cb); + if pass_edit.enter_pressed { + on_next(self); + } + + // Show information when password is empty or wrong. + if self.pass_edit.is_empty() { + ui.add_space(12.0); + ui.label( + RichText::new(t!("wallets.pass_empty")) + .size(17.0) + .color(Colors::inactive_text()), + ); + } else if self.wrong_pass { + ui.add_space(12.0); + ui.label( + RichText::new(t!("wallets.wrong_pass")) + .size(17.0) + .color(Colors::red()), + ); + } + }); + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + self.recovery_phrase = None; + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, "OK".to_owned(), Colors::white_or_black(false), || { + on_next(self); + }); + }); + }); + }); + } + ui.add_space(6.0); + } + + /// Draw wallet deletion [`Modal`] content. + pub fn deletion_modal_ui(ui: &mut egui::Ui, wallet: &Wallet) { + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.delete_conf")) + .size(17.0) + .color(Colors::text(false)), + ); + }); + ui.add_space(12.0); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, t!("delete"), Colors::white_or_black(false), || { + wallet.delete_wallet(); + Modal::close(); + }); + }); + }); + ui.add_space(6.0); + }); + } +} diff --git a/src/gui/views/wallets/wallet/txs/content.rs b/src/gui/views/wallets/wallet/txs/content.rs new file mode 100644 index 00000000..d4f812f1 --- /dev/null +++ b/src/gui/views/wallets/wallet/txs/content.rs @@ -0,0 +1,807 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use egui::epaint::RectShape; +use egui::scroll_area::ScrollBarVisibility; +use egui::{ + Align, Color32, CornerRadius, CursorIcon, Id, Layout, Rect, RichText, ScrollArea, Sense, + StrokeKind, UiBuilder, +}; +use grin_core::consensus::COINBASE_MATURITY; +use grin_core::core::amount_to_hr_string; +use grin_wallet_libwallet::TxLogEntryType; +use std::ops::Range; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::gui::Colors; +use crate::gui::icons::{ + ARROW_CIRCLE_DOWN, ARROW_CIRCLE_UP, ARROWS_CLOCKWISE, CALENDAR_CHECK, DOTS_THREE_CIRCLE, + FILE_ARROW_DOWN, FILE_TEXT, FILE_X, GEAR_FINE, PROHIBIT, WARNING, X_CIRCLE, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::{LinePosition, ModalPosition}; +use crate::gui::views::wallets::wallet::WalletTransactionContent; +use crate::gui::views::wallets::wallet::message::MessageInputContent; +use crate::gui::views::wallets::wallet::types::{GRIN, WalletContentContainer}; +use crate::gui::views::{Content, Modal, PullToRefresh, View}; +use crate::wallet::Wallet; +use crate::wallet::types::{WalletData, WalletTask, WalletTx, WalletTxAction}; + +/// Wallet transactions tab content. +pub struct WalletTransactionsContent { + /// Transaction information [`Modal`] content. + pub tx_info_content: Option, + /// Message input [`Modal`] content. + pub message_content: Option, + + /// Transaction identifier to use at confirmation [`Modal`] to cancel. + confirm_cancel_tx_id: Option, + /// Transaction identifier to use at confirmation [`Modal`] to delete. + confirm_delete_tx_id: Option, + + /// Flag to check if sync of wallet was initiated manually at time. + manual_sync: Option, +} + +/// Identifier for transaction information [`Modal`]. +const TX_INFO_MODAL: &'static str = "tx_info_modal"; +/// Identifier for transaction cancellation confirmation [`Modal`]. +const CANCEL_TX_CONFIRMATION_MODAL: &'static str = "cancel_tx_conf_modal"; +/// Identifier for transaction deletion confirmation [`Modal`]. +const DELETE_TX_CONFIRMATION_MODAL: &'static str = "delete_tx_conf_modal"; + +impl WalletContentContainer for WalletTransactionsContent { + fn modal_ids(&self) -> Vec<&'static str> { + vec![ + TX_INFO_MODAL, + CANCEL_TX_CONFIRMATION_MODAL, + DELETE_TX_CONFIRMATION_MODAL, + MessageInputContent::MODAL_ID, + ] + } + + fn modal_ui(&mut self, ui: &mut egui::Ui, w: &Wallet, m: &Modal, cb: &dyn PlatformCallbacks) { + match m.id { + TX_INFO_MODAL => { + if let Some(content) = self.tx_info_content.as_mut() { + let mut on_delete_id = None; + content.ui(ui, m, w, cb, |id| { + on_delete_id = Some(id); + }); + if let Some(id) = on_delete_id { + self.show_delete_confirmation_modal(id); + } + } + } + CANCEL_TX_CONFIRMATION_MODAL => { + self.cancel_confirmation_modal(ui, w); + } + DELETE_TX_CONFIRMATION_MODAL => { + self.delete_confirmation_modal(ui, w); + } + MessageInputContent::MODAL_ID => { + if self.message_content.is_none() { + self.message_content = Some(MessageInputContent::default()); + } + self.message_content.as_mut().unwrap().ui(ui, w, m, cb); + } + _ => {} + } + } + + fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, _: &dyn PlatformCallbacks) { + self.txs_ui(ui, wallet); + } +} + +impl WalletTransactionsContent { + /// Height of transaction list item. + pub const TX_ITEM_HEIGHT: f32 = 75.0; + + /// Create new content instance with opening tx info. + pub fn new(tx: Option) -> Self { + let mut content = Self { + tx_info_content: None, + message_content: None, + confirm_cancel_tx_id: None, + confirm_delete_tx_id: None, + manual_sync: None, + }; + if let Some(tx) = &tx { + content.show_tx_info_modal(tx.data.id); + } + content + } + + /// Draw transactions content. + fn txs_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let data = wallet.get_data(); + if data.is_none() { + return; + } + let data = data.unwrap(); + let config = wallet.get_config(); + if data.txs.is_none() { + ui.centered_and_justified(|ui| { + View::big_loading_spinner(ui); + }); + return; + } + let txs = data + .txs + .as_ref() + .unwrap() + .iter() + .filter(|tx| !tx.deleting()) + .collect::>(); + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + // Show message when txs are empty. + if txs.is_empty() { + View::center_content(ui, 96.0, |ui| { + let empty_text = t!( + "wallets.txs_empty", + "message" => FILE_ARROW_DOWN, + "transport" => FILE_TEXT, + "settings" => GEAR_FINE + ); + ui.label( + RichText::new(empty_text) + .size(16.0) + .color(Colors::inactive_text()), + ); + }); + return; + } + // Draw awaiting amount info if exists. + self.awaiting_info_ui(ui, &data); + }); + ui.add_space(4.0); + + // Show list of transactions. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + let refresh = self.manual_sync.unwrap_or(0) + 1600 > now; + let refresh_resp = PullToRefresh::new(refresh) + .id(Id::from("refresh_tx_list").with(config.id)) + .can_refresh(!refresh && !wallet.syncing() && !txs.is_empty()) + .min_refresh_distance(70.0) + .scroll_area_ui(ui, |ui| { + let rows_size = if txs.is_empty() { + 0 + } else { + // Last index is for list pagination. + txs.len() + 1 + }; + ScrollArea::vertical() + .id_salt(Id::from("wallet_tx_list_scroll").with(config.id)) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) + .show_rows(ui, Self::TX_ITEM_HEIGHT, rows_size, |ui, row_range| { + ui.add_space(1.0); + View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { + self.tx_list_ui(ui, row_range, &wallet, &data, txs); + }); + }) + }); + + // Sync wallet on refresh. + if refresh_resp.should_refresh() { + self.manual_sync = Some(now); + if !wallet.syncing() { + wallet.sync(); + } + } + } + + /// Draw transaction list content. + fn tx_list_ui( + &mut self, + ui: &mut egui::Ui, + row_range: Range, + wallet: &Wallet, + data: &WalletData, + txs: Vec<&WalletTx>, + ) { + for index in row_range { + if index == txs.len() && ui.is_visible() { + // Load more txs when needed. + if !wallet.more_txs_loading() { + if let Some(data) = wallet.get_data() { + if txs.len() as u32 >= data.txs_limit { + wallet.load_more_txs(); + } + } + } + // Show loader when more txs are loading. + if wallet.more_txs_loading() { + ui.vertical_centered(|ui| { + ui.add_space(24.0); + View::small_loading_spinner(ui); + }); + } + return; + } + // Transaction item background setup. + let rect = { + let mut r = ui.available_rect_before_wrap(); + r.min += egui::emath::vec2(6.0, 0.0); + r.max -= egui::emath::vec2(6.0, 0.0); + r.set_height(Self::TX_ITEM_HEIGHT); + r + }; + let rounding = View::item_rounding(index, txs.len(), false); + let bg = Colors::fill(); + let tx = txs.get(index).unwrap(); + let mut show_tx_info = false; + // Draw transaction list item. + Self::tx_item_ui( + ui, + tx, + rect, + bg, + rounding, + &data, + (true, || { + show_tx_info = true; + }), + |ui| { + let btn_rounding = { + let mut r = rounding.clone(); + r.nw = 0.0 as u8; + r.sw = 0.0 as u8; + r + }; + // Draw button to delete transaction. + if tx.data.confirmed || tx.cancelled() { + View::item_button( + ui, + btn_rounding, + FILE_X, + Some(Colors::inactive_text()), + || { + self.show_delete_confirmation_modal(tx.data.id); + }, + ); + } else if !tx.cancelled() + && !tx.cancelling() + && !tx.posting() && wallet.synced_from_node() + { + let repeat = tx.broadcasting_timed_out(wallet); + // Draw button to cancel transaction. + if tx.can_cancel() || repeat { + let (icon, color) = (PROHIBIT, Some(Colors::red())); + View::item_button(ui, btn_rounding, icon, color, || { + self.confirm_cancel_tx_id = Some(tx.data.id); + // Show transaction cancellation confirmation modal. + Modal::new(CANCEL_TX_CONFIRMATION_MODAL) + .position(ModalPosition::Center) + .title(t!("confirmation")) + .show(); + }); + } + // Draw button to repeat transaction action. + if tx.can_repeat_action(wallet) || repeat { + Self::tx_repeat_button_ui( + ui, + CornerRadius::default(), + tx, + wallet, + repeat, + ); + } + } + }, + ); + // Show transaction info on click. + if show_tx_info { + self.show_tx_info_modal(tx.data.id); + } + } + } + + /// Draw information about locked, finalizing or confirming balance. + fn awaiting_info_ui(&mut self, ui: &mut egui::Ui, data: &WalletData) { + let amount_conf = data.info.amount_awaiting_confirmation; + let amount_fin = data.info.amount_awaiting_finalization; + let amount_locked = data.info.amount_locked; + if amount_conf == 0 && amount_fin == 0 && amount_locked == 0 { + return; + } + ui.add_space(-1.0); + let rect = ui.available_rect_before_wrap(); + // Draw background. + let mut bg = RectShape::new( + rect, + CornerRadius { + nw: 0.0 as u8, + ne: 0.0 as u8, + sw: 8.0 as u8, + se: 8.0 as u8, + }, + Colors::fill(), + View::item_stroke(), + StrokeKind::Outside, + ); + let bg_idx = ui.painter().add(bg.clone()); + let resp = ui + .allocate_ui(rect.size(), |ui| { + ui.vertical_centered_justified(|ui| { + // Correct vertical spacing between items. + ui.style_mut().spacing.item_spacing.y = -3.0; + if amount_conf != 0 { + // Draw awaiting confirmation amount. + awaiting_item_ui(ui, amount_conf, t!("wallets.await_conf_amount")); + } + if amount_fin != 0 { + // Draw awaiting confirmation amount. + awaiting_item_ui(ui, amount_fin, t!("wallets.await_fin_amount")); + } + if amount_locked != 0 { + // Draw locked amount. + awaiting_item_ui(ui, amount_locked, t!("wallets.locked_amount")); + } + }); + }) + .response; + // Setup background size. + bg.rect = resp.rect; + ui.painter().set(bg_idx, bg); + } + + /// Draw transaction item. + pub fn tx_item_ui( + ui: &mut egui::Ui, + tx: &WalletTx, + rect: Rect, + bg: Color32, + r: CornerRadius, + data: &WalletData, + on_click: (bool, impl FnOnce()), + buttons_ui: impl FnOnce(&mut egui::Ui), + ) { + // Draw background. + let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside); + let bg_idx = ui.painter().add(bg_shape.clone()); + let res = ui + .scope_builder( + UiBuilder::new() + .sense(Sense::click()) + .layout(Layout::right_to_left(Align::Center)) + .max_rect(rect), + |ui| { + ui.horizontal_centered(|ui| { + // Draw buttons. + buttons_ui(ui); + }); + + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + + // Setup transaction amount. + let mut amount_text = if tx.data.tx_type == TxLogEntryType::TxSent + || tx.data.tx_type == TxLogEntryType::TxSentCancelled + { + "-" + } else if tx.data.tx_type == TxLogEntryType::TxReceived + || tx.data.tx_type == TxLogEntryType::TxReceivedCancelled + { + "+" + } else { + "" + } + .to_string(); + amount_text = format!( + "{}{} {}", + amount_text, + amount_to_hr_string(tx.amount, true), + GRIN + ); + + // Setup amount color. + let amount_color = match tx.data.tx_type { + TxLogEntryType::ConfirmedCoinbase => Colors::white_or_black(true), + TxLogEntryType::TxReceived => Colors::white_or_black(true), + TxLogEntryType::TxSent => Colors::white_or_black(true), + TxLogEntryType::TxReceivedCancelled => Colors::text(false), + TxLogEntryType::TxSentCancelled => Colors::text(false), + TxLogEntryType::TxReverted => Colors::text(false), + }; + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.add_space(1.0); + View::ellipsize_text(ui, amount_text, 18.0, amount_color); + }); + ui.add_space(-2.0); + + // Setup transaction status text. + let height = data.info.last_confirmed_height; + let status_text = if !tx.data.confirmed { + let is_canceled = tx.data.tx_type + == TxLogEntryType::TxSentCancelled + || tx.data.tx_type == TxLogEntryType::TxReceivedCancelled; + if is_canceled { + format!("{} {}", X_CIRCLE, t!("wallets.tx_canceled")) + } else if let Some(action) = &tx.action { + let error = if tx.action_error.is_none() { + "".to_string() + } else { + format!("{}: ", t!("error")) + }; + let status = match action { + WalletTxAction::Finalizing => t!("wallets.tx_finalizing"), + WalletTxAction::Posting => t!("wallets.tx_posting"), + _ => t!("wallets.tx_cancelling"), + }; + let icon = if error.is_empty() { + DOTS_THREE_CIRCLE + } else { + WARNING + }; + format!("{} {}{}", icon, error, status) + } else { + match tx.data.tx_type { + TxLogEntryType::TxReceived => { + let text = match tx.finalized() { + true => t!("wallets.await_fin_amount"), + false => t!("wallets.tx_receiving"), + }; + format!("{} {}", DOTS_THREE_CIRCLE, text) + } + TxLogEntryType::TxSent => { + let text = match tx.finalized() { + true => t!("wallets.await_fin_amount"), + false => t!("wallets.tx_sending"), + }; + format!("{} {}", DOTS_THREE_CIRCLE, text) + } + TxLogEntryType::ConfirmedCoinbase => { + let tx_h = tx.height.unwrap_or(1) - 1; + if tx_h != 0 { + let left_conf = height - tx_h; + if height >= tx_h && left_conf < COINBASE_MATURITY { + let conf_info = format!( + "{}/{}", + left_conf, COINBASE_MATURITY + ); + format!( + "{} {} {}", + DOTS_THREE_CIRCLE, + t!("wallets.tx_confirming"), + conf_info + ) + } else { + format!( + "{} {}", + DOTS_THREE_CIRCLE, + t!("wallets.tx_confirming") + ) + } + } else { + format!( + "{} {}", + DOTS_THREE_CIRCLE, + t!("wallets.tx_confirming") + ) + } + } + _ => { + format!( + "{} {}", + DOTS_THREE_CIRCLE, + t!("wallets.tx_confirming") + ) + } + } + } + } else { + match tx.data.tx_type { + TxLogEntryType::ConfirmedCoinbase => { + let tx_h = tx.height.unwrap_or(1) - 1; + if tx_h != 0 { + let left_conf = height - tx_h; + if height >= tx_h && left_conf < COINBASE_MATURITY { + let conf_info = + format!("{}/{}", left_conf, COINBASE_MATURITY); + format!( + "{} {} {}", + DOTS_THREE_CIRCLE, + t!("wallets.tx_confirming"), + conf_info + ) + } else { + format!( + "{} {}", + DOTS_THREE_CIRCLE, + t!("wallets.tx_confirmed") + ) + } + } else { + format!( + "{} {}", + DOTS_THREE_CIRCLE, + t!("wallets.tx_confirmed") + ) + } + } + TxLogEntryType::TxSent | TxLogEntryType::TxReceived => { + let min_conf = data.info.minimum_confirmations; + if tx.height.is_none() + || (tx.height.unwrap() != 0 + && height - tx.height.unwrap() >= min_conf - 1) + { + let (i, t) = + if tx.data.tx_type == TxLogEntryType::TxSent { + (ARROW_CIRCLE_UP, t!("wallets.tx_sent")) + } else { + (ARROW_CIRCLE_DOWN, t!("wallets.tx_received")) + }; + format!("{} {}", i, t) + } else { + let tx_height = tx.height.unwrap() - 1; + let left_conf = height - tx_height; + let conf_info = if tx_height != 0 + && height >= tx_height && left_conf + < min_conf + { + format!("{}/{}", left_conf, min_conf) + } else { + "".to_string() + }; + format!( + "{} {} {}", + DOTS_THREE_CIRCLE, + t!("wallets.tx_confirming"), + conf_info + ) + } + } + _ => format!("{} {}", X_CIRCLE, t!("wallets.canceled")), + } + }; + + // Setup status text color. + let status_color = match tx.data.tx_type { + TxLogEntryType::ConfirmedCoinbase => Colors::text(false), + TxLogEntryType::TxReceived => { + if tx.data.confirmed { + Colors::green() + } else { + Colors::text(false) + } + } + TxLogEntryType::TxSent => { + if tx.data.confirmed { + Colors::red() + } else { + Colors::text(false) + } + } + TxLogEntryType::TxReceivedCancelled => Colors::inactive_text(), + TxLogEntryType::TxSentCancelled => Colors::inactive_text(), + TxLogEntryType::TxReverted => Colors::inactive_text(), + }; + View::ellipsize_text(ui, status_text, 15.0, status_color); + + // Setup transaction time. + let tx_time = View::format_time(tx.data.creation_ts.timestamp()); + let tx_time_text = format!("{} {}", CALENDAR_CHECK, tx_time); + ui.label(RichText::new(tx_time_text).size(15.0).color(Colors::gray())); + ui.add_space(4.0); + }); + }); + }, + ) + .response; + let (clickable, on_click) = on_click; + let clicked = res.clicked() || res.long_touched(); + // Setup background and cursor. + if clickable && res.hovered() { + res.on_hover_cursor(CursorIcon::PointingHand); + bg_shape.fill = Colors::TRANSPARENT; + } + ui.painter().set(bg_idx, bg_shape); + // Handle clicks on layout. + if clicked && clickable { + on_click(); + } + } + + /// Draw button to repeat transaction action on error or repost. + pub fn tx_repeat_button_ui( + ui: &mut egui::Ui, + rounding: CornerRadius, + tx: &WalletTx, + wallet: &Wallet, + repost: bool, + ) { + let (icon, color) = (ARROWS_CLOCKWISE, Some(Colors::green())); + View::item_button(ui, rounding, icon, color, || { + if repost { + wallet.task(WalletTask::Post(tx.data.id)); + } else if let Some(action) = tx.action.as_ref() { + match action { + WalletTxAction::Finalizing => { + wallet.task(WalletTask::Finalize(tx.data.id)); + } + WalletTxAction::Posting => { + wallet.task(WalletTask::Post(tx.data.id)); + } + _ => {} + } + } + }); + } + + /// Show transaction information [`Modal`]. + fn show_tx_info_modal(&mut self, id: u32) { + let modal = WalletTransactionContent::new(id); + self.message_content = None; + self.tx_info_content = Some(modal); + Modal::new(TX_INFO_MODAL) + .position(ModalPosition::Center) + .title(t!("wallets.tx")) + .show(); + } + + /// Confirmation [`Modal`] to cancel transaction. + fn cancel_confirmation_modal(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let data = wallet.get_data(); + if data.is_none() { + Modal::close(); + return; + } + let data = wallet.get_data().unwrap(); + let data_txs = data.txs.unwrap(); + let txs = data_txs + .into_iter() + .filter(|tx| tx.data.id == self.confirm_cancel_tx_id.unwrap_or_default()) + .collect::>(); + if txs.is_empty() { + Modal::close(); + return; + } + let tx = txs.get(0).unwrap(); + let amount = amount_to_hr_string(tx.amount, true); + let text = match tx.data.tx_type { + TxLogEntryType::TxReceived => { + t!("wallets.tx_receive_cancel_conf", "amount" => amount) + } + _ => { + t!("wallets.tx_send_cancel_conf", "amount" => amount) + } + }; + + // Show confirmation text. + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label(RichText::new(text).size(17.0).color(Colors::text(false))); + ui.add_space(8.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + self.confirm_cancel_tx_id = None; + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, "OK".to_string(), Colors::white_or_black(false), || { + wallet.task(WalletTask::Cancel(tx.data.id)); + self.confirm_cancel_tx_id = None; + Modal::close(); + }); + }); + }); + ui.add_space(6.0); + }); + } + + /// Show transaction deletion confirmation [`Modal`]. + fn show_delete_confirmation_modal(&mut self, id: u32) { + self.confirm_delete_tx_id = Some(id); + // Show transaction deletion confirmation modal. + Modal::new(DELETE_TX_CONFIRMATION_MODAL) + .position(ModalPosition::Center) + .title(t!("confirmation")) + .show(); + } + + /// Confirmation [`Modal`] to delete transaction. + fn delete_confirmation_modal(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let data = wallet.get_data(); + if data.is_none() { + Modal::close(); + return; + } + let data = data.unwrap(); + let data_txs = data.txs.unwrap(); + let txs = data_txs + .into_iter() + .filter(|tx| tx.data.id == self.confirm_delete_tx_id.unwrap_or_default()) + .collect::>(); + if txs.is_empty() { + Modal::close(); + return; + } + let tx = txs.get(0).unwrap(); + + // Show confirmation text. + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("wallets.tx_delete_confirmation")) + .size(17.0) + .color(Colors::text(false)), + ); + ui.add_space(8.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + self.confirm_delete_tx_id = None; + Modal::close(); + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button(ui, "OK".to_string(), Colors::white_or_black(false), || { + wallet.task(WalletTask::Delete(tx.data.id)); + self.confirm_delete_tx_id = None; + Modal::close(); + }); + }); + }); + ui.add_space(6.0); + }); + } +} + +/// Draw awaiting balance item content. +fn awaiting_item_ui(ui: &mut egui::Ui, amount: u64, label: impl Into) { + let rect = ui.available_rect_before_wrap(); + View::line(ui, LinePosition::TOP, &rect, Colors::item_stroke()); + ui.add_space(4.0); + let amount_format = amount_to_hr_string(amount, true); + ui.label( + RichText::new(format!("{} ツ", amount_format)) + .color(Colors::white_or_black(true)) + .size(17.0), + ); + ui.label(RichText::new(label).color(Colors::gray()).size(15.0)); + ui.add_space(8.0); +} diff --git a/src/gui/views/wallets/wallet/txs/mod.rs b/src/gui/views/wallets/wallet/txs/mod.rs new file mode 100644 index 00000000..613ed6e0 --- /dev/null +++ b/src/gui/views/wallets/wallet/txs/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +mod content; +pub use content::*; + +mod tx; +pub use tx::*; diff --git a/src/gui/views/wallets/wallet/txs/tx.rs b/src/gui/views/wallets/wallet/txs/tx.rs new file mode 100644 index 00000000..051ba8e7 --- /dev/null +++ b/src/gui/views/wallets/wallet/txs/tx.rs @@ -0,0 +1,431 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use egui::scroll_area::ScrollBarVisibility; +use egui::{Align, CornerRadius, Id, Layout, RichText, ScrollArea, StrokeKind}; +use grin_core::core::amount_to_hr_string; +use grin_util::ToHex; +use grin_wallet_libwallet::TxLogEntryType; +use std::fs; + +use crate::AppConfig; +use crate::gui::Colors; +use crate::gui::icons::{ + CIRCLE_HALF, COPY, CUBE, FILE_ARCHIVE, FILE_TEXT, FILE_X, HASH_STRAIGHT, PROHIBIT, QR_CODE, + SEAL_CHECK, +}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::types::ModalPosition; +use crate::gui::views::wallets::wallet::message::MessageInputContent; +use crate::gui::views::wallets::wallet::proof::PaymentProofContent; +use crate::gui::views::wallets::wallet::txs::WalletTransactionsContent; +use crate::gui::views::{Modal, QrCodeContent, View}; +use crate::wallet::Wallet; +use crate::wallet::types::{WalletTask, WalletTx}; + +/// Transaction information [`Modal`] content. +pub struct WalletTransactionContent { + /// Transaction identifier. + tx_id: u32, + /// Slatepack message text. + message: Option, + + /// QR code Slatepack message image content. + qr_code_content: Option, + + /// Payment proof sharing content. + pub proof_content: Option, +} + +impl WalletTransactionContent { + /// Create new content instance with [`Wallet`] from provided [`WalletTx`]. + pub fn new(tx_id: u32) -> Self { + Self { + tx_id, + message: None, + qr_code_content: None, + proof_content: None, + } + } + + /// Draw [`Modal`] content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + on_delete: impl FnOnce(u32), + ) { + // Check values and setup transaction data. + let wallet_data = wallet.get_data(); + if wallet_data.is_none() { + Modal::close(); + return; + } + let data = wallet_data.unwrap(); + let data_txs = data.txs.clone().unwrap(); + let txs = data_txs + .into_iter() + .filter(|tx| tx.data.id == self.tx_id) + .collect::>(); + if txs.is_empty() { + Modal::close(); + return; + } + let tx = txs.get(0).unwrap(); + + if let Some(content) = self.qr_code_content.as_mut() { + let dark_theme = AppConfig::dark_theme().unwrap_or(false); + // Set light theme for better scanning. + AppConfig::set_dark_theme(false); + crate::setup_visuals(ui.ctx()); + modal.set_background_color(Colors::FILL_DEEP); + + ui.add_space(6.0); + content.ui(ui, cb); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + // Show buttons to close modal or come back to text request content. + ui.columns(2, |cols| { + cols[0].vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + self.qr_code_content = None; + Modal::close(); + }); + }); + cols[1].vertical_centered_justified(|ui| { + View::button(ui, t!("back"), Colors::white_or_black(false), || { + self.qr_code_content = None; + }); + }); + }); + // Set color theme back. + AppConfig::set_dark_theme(dark_theme); + crate::setup_visuals(ui.ctx()); + } else { + modal.set_background_color(Colors::fill()); + // Show transaction information. + self.info_ui(ui, tx, wallet, cb, on_delete); + + // Show transaction sharing content or payment proof. + if self.proof_content.is_none() && tx.can_cancel() && !tx.finalized() { + self.share_ui(ui, wallet, tx, cb); + } else { + if let Some(proof_content) = self.proof_content.as_mut() { + // Draw payment proof sharing content. + proof_content.share_ui(ui, tx, cb); + } else if tx.proof.is_some() && tx.action_error.is_none() { + ui.vertical_centered(|ui| { + ui.add_space(8.0); + let label = format!("{} {}", SEAL_CHECK, t!("wallets.payment_proof")); + let text_color = Colors::gold_dark(); + let btn_color = Colors::white_or_black(false); + // Draw button to show payment proof sharing content. + View::colored_text_button(ui, label, text_color, btn_color, || { + if let Ok(p) = serde_json::to_string_pretty(&tx.proof) { + let c = PaymentProofContent::new(Some(p)); + self.proof_content = Some(c); + } + }); + }); + } + } + + ui.add_space(8.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Show button to close modal. + ui.vertical_centered_justified(|ui| { + View::button(ui, t!("close"), Colors::white_or_black(false), || { + Modal::close(); + }); + }); + } + ui.add_space(6.0); + } + + /// Draw transaction sharing content. + fn share_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + tx: &WalletTx, + cb: &dyn PlatformCallbacks, + ) { + if self.message.is_none() { + let slatepack_path = wallet + .get_config() + .get_slate_path(tx.data.tx_slate_id.unwrap(), &tx.state); + self.message = Some(fs::read_to_string(slatepack_path).unwrap_or("".to_string())); + } + if let Some(m) = &self.message { + if m.is_empty() { + return; + } + + let amount = amount_to_hr_string(tx.amount, true); + let desc_text = if tx.can_finalize() { + if tx.data.tx_type == TxLogEntryType::TxSent { + t!("wallets.send_request_desc", "amount" => amount) + } else { + t!("wallets.invoice_desc", "amount" => amount) + } + } else { + if tx.data.tx_type == TxLogEntryType::TxSent { + t!("wallets.parse_i1_slatepack_desc", "amount" => amount) + } else { + t!("wallets.parse_s1_slatepack_desc", "amount" => amount) + } + }; + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(desc_text) + .size(16.0) + .color(Colors::inactive_text()), + ); + }); + ui.add_space(6.0); + + let mut message = m.clone(); + // Draw Slatepack message content. + ui.vertical_centered(|ui| { + let scroll_id = Id::from("tx_info_message_request").with(tx.data.id); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(3.0); + ScrollArea::vertical() + .id_salt(scroll_id) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .max_height(128.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(7.0); + let input_id = scroll_id.with("_input"); + egui::TextEdit::multiline(&mut message) + .id(input_id) + .font(egui::TextStyle::Small) + .desired_rows(5) + .interactive(false) + .desired_width(f32::INFINITY) + .show(ui); + ui.add_space(6.0); + }); + }); + + ui.add_space(2.0); + View::horizontal_line(ui, Colors::item_stroke()); + ui.add_space(8.0); + + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + let mut finalization_needed = false; + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + // Draw button to show Slatepack message as QR code. + let qr_text = format!("{} {}", QR_CODE, t!("qr_code")); + View::button(ui, qr_text, Colors::white_or_black(false), || { + self.qr_code_content = Some(QrCodeContent::new(message, true)); + }); + }); + columns[1].vertical_centered_justified(|ui| { + // Draw copy button. + let copy_text = format!("{} {}", COPY, t!("copy")); + View::button(ui, copy_text, Colors::white_or_black(false), || { + cb.copy_string_to_buffer(m.clone()); + // Show message input or close modal. + if tx.can_finalize() { + finalization_needed = true; + } else { + Modal::close(); + } + }); + }); + }); + + // Draw button to share response as file. + ui.add_space(8.0); + ui.vertical_centered(|ui| { + let share_text = format!("{} {}", FILE_TEXT, t!("share")); + View::colored_text_button( + ui, + share_text, + Colors::blue(), + Colors::white_or_black(false), + || { + if let Some(slate_id) = tx.data.tx_slate_id { + let name = format!("{}.{}.slatepack", slate_id, tx.state); + let data = m.as_bytes().to_vec(); + cb.share_data(name, data).unwrap_or_default(); + // Show message input or close modal. + if tx.can_finalize() { + finalization_needed = true; + } else { + Modal::close(); + } + } + }, + ); + }); + + if finalization_needed { + Modal::new(MessageInputContent::MODAL_ID) + .position(ModalPosition::Center) + .title(t!("wallets.messages")) + .show(); + } + } + } + + /// Draw transaction information content. + fn info_ui( + &mut self, + ui: &mut egui::Ui, + tx: &WalletTx, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + on_delete: impl FnOnce(u32), + ) { + let data = wallet.get_data(); + if data.is_none() { + Modal::close(); + return; + } + let data = wallet.get_data().unwrap(); + + ui.add_space(6.0); + // Transaction item background setup. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(WalletTransactionsContent::TX_ITEM_HEIGHT); + let rounding = View::item_rounding(0, 2, false); + let bg = Colors::TRANSPARENT; + // Show transaction amount status and time. + let on_click = (false, || {}); + WalletTransactionsContent::tx_item_ui(ui, tx, rect, bg, rounding, &data, on_click, |ui| { + // Show button to delete transaction from database. + if tx.data.confirmed || tx.cancelled() { + let r = View::item_rounding(0, 2, true); + View::item_button(ui, r, FILE_X, Some(Colors::inactive_text()), || { + on_delete(tx.data.id); + }); + } + // Show block height or buttons. + if let Some(h) = tx.height { + if h != 0 { + ui.add_space(6.0); + let height = format!("{} {}", CUBE, h.to_string()); + ui.with_layout(Layout::bottom_up(Align::Max), |ui| { + ui.add_space(3.0); + ui.label(RichText::new(height).size(15.0).color(Colors::text(false))); + }); + } + return; + } + if wallet.synced_from_node() && !tx.cancelled() && !tx.cancelling() && !tx.posting() { + let repeat = tx.broadcasting_timed_out(&wallet); + // Draw button to cancel transaction. + if tx.can_cancel() || repeat { + let r = View::item_rounding(0, 2, true); + View::item_button(ui, r, PROHIBIT, Some(Colors::red()), || { + wallet.task(WalletTask::Cancel(tx.data.id)); + Modal::close(); + }); + } + // Draw button to repeat transaction action. + if tx.can_repeat_action(wallet) || repeat { + let r = if tx.can_finalize() || tx.can_cancel() { + CornerRadius::default() + } else { + View::item_rounding(0, 2, true) + }; + WalletTransactionsContent::tx_repeat_button_ui(ui, r, tx, wallet, repeat); + } + } + }); + + // Show identifier. + if let Some(id) = tx.data.tx_slate_id { + let label = format!("{} {}", HASH_STRAIGHT, t!("id")); + info_item_ui(ui, id.to_string(), label, true, cb); + } + // Show kernel. + if let Some(kernel) = tx.data.kernel_excess { + let label = format!("{} {}", FILE_ARCHIVE, t!("kernel")); + info_item_ui(ui, kernel.0.to_hex(), label, true, cb); + } + // Show receiver or sender address. + let addr = if tx.data.tx_type == TxLogEntryType::TxSent { + &tx.receiver + } else { + &tx.sender + }; + if let Some(addr) = addr { + let label = format!("{} {}", CIRCLE_HALF, t!("network_mining.address")); + info_item_ui(ui, addr.to_string(), label, true, cb); + } + } +} + +/// Draw transaction information item content. +fn info_item_ui( + ui: &mut egui::Ui, + value: String, + label: String, + copy: bool, + cb: &dyn PlatformCallbacks, +) { + // Setup layout size. + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(50.0); + + // Draw round background. + let bg_rect = rect.clone(); + let mut rounding = View::item_rounding(1, 3, false); + + ui.painter().rect( + bg_rect, + rounding, + Colors::fill(), + View::item_stroke(), + StrokeKind::Outside, + ); + + ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| { + // Draw button to copy transaction info value. + if copy { + rounding.nw = 0.0 as u8; + rounding.sw = 0.0 as u8; + View::item_button(ui, rounding, COPY, None, || { + cb.copy_string_to_buffer(value.clone()); + }); + } + + // Draw value information. + let layout_size = ui.available_size(); + ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + View::ellipsize_text(ui, value, 15.0, Colors::title(false)); + ui.label(RichText::new(label).size(15.0).color(Colors::gray())); + ui.add_space(3.0); + }); + }); + }); +} diff --git a/src/gui/views/wallets/wallet/types.rs b/src/gui/views/wallets/wallet/types.rs new file mode 100644 index 00000000..b3d98bc2 --- /dev/null +++ b/src/gui/views/wallets/wallet/types.rs @@ -0,0 +1,81 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use crate::gui::icons::{FOLDER_LOCK, FOLDER_OPEN, SPINNER, WARNING_CIRCLE}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::Modal; +use crate::wallet::Wallet; + +/// GRIN coin symbol. +pub const GRIN: &str = "ツ"; + +/// Content container to simplify modals management and navigation. +pub trait WalletContentContainer { + /// List of allowed [`Modal`] identifiers. + fn modal_ids(&self) -> Vec<&'static str>; + /// Draw modal content. + fn modal_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + modal: &Modal, + cb: &dyn PlatformCallbacks, + ); + /// Draw container content. + fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks); + /// Draw content, to call by parent container. + fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + // Draw modal content. + if let Some(id) = Modal::opened() { + if self.modal_ids().contains(&id) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.modal_ui(ui, wallet, modal, cb); + }); + } + } + self.container_ui(ui, wallet, cb); + } +} + +/// Get wallet status text. +pub fn wallet_status_text(wallet: &Wallet) -> String { + if wallet.sync_error() && wallet.is_open() { + format!("{} {}", WARNING_CIRCLE, t!("error")) + } else if wallet.is_closing() { + format!("{} {}", SPINNER, t!("wallets.closing")) + } else if wallet.is_repairing() { + let repair_progress = wallet.repairing_progress(); + if repair_progress == 0 { + format!("{} {}", SPINNER, t!("wallets.checking")) + } else { + format!( + "{} {}: {}%", + SPINNER, + t!("wallets.checking"), + repair_progress + ) + } + } else if wallet.syncing() { + let info_progress = wallet.info_sync_progress(); + if info_progress == 100 || info_progress == 0 { + format!("{} {}", SPINNER, t!("wallets.loading")) + } else { + format!("{} {}: {}%", SPINNER, t!("wallets.loading"), info_progress) + } + } else if wallet.is_open() { + format!("{} {}", FOLDER_OPEN, t!("wallets.unlocked")) + } else { + format!("{} {}", FOLDER_LOCK, t!("wallets.locked")) + } +} diff --git a/src/http/client.rs b/src/http/client.rs new file mode 100644 index 00000000..7ea01096 --- /dev/null +++ b/src/http/client.rs @@ -0,0 +1,91 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use hyper::body::{Body, Incoming}; +use hyper::{Request, Response}; +use hyper_proxy2::{Intercept, Proxy, ProxyConnector}; +use hyper_tls::HttpsConnector; +use hyper_util::client::legacy::{Client, Error}; +use hyper_util::rt::TokioExecutor; +use serde::de::StdError; + +use crate::AppConfig; + +/// Handles http requests. +pub struct HttpClient {} + +impl HttpClient { + /// Send request. + pub async fn send(req: Request) -> Result, Error> + where + B: Body + Send + 'static + Unpin, + ::Data: Send, + B::Data: Send, + B::Error: Into>, + { + if AppConfig::use_proxy() { + if let Some(url) = AppConfig::socks_proxy_url() { + Self::send_socks_proxy(url, req).await + } else { + Self::send_http_proxy(AppConfig::http_proxy_url().unwrap(), req).await + } + } else { + let client = Client::builder(TokioExecutor::new()).build::<_, B>(HttpsConnector::new()); + client.request(req).await + } + } + + /// Create socks proxy client. + pub async fn send_socks_proxy( + proxy_url: String, + req: Request, + ) -> Result, Error> + where + B: Body + Send + 'static + Unpin, + ::Data: Send, + B::Data: Send, + B::Error: Into>, + { + let connector = HttpsConnector::new(); + let uri = proxy_url.parse().unwrap(); + let proxy = hyper_socks2::SocksConnector { + proxy_addr: uri, + auth: None, + connector, + } + .with_tls() + .unwrap(); + let client = Client::builder(TokioExecutor::new()).build::<_, B>(proxy); + client.request(req).await + } + + /// Create http proxy client. + pub async fn send_http_proxy( + proxy_url: String, + req: Request, + ) -> Result, Error> + where + B: Body + Send + 'static + Unpin, + ::Data: Send, + B::Data: Send, + B::Error: Into>, + { + let uri = proxy_url.parse().unwrap(); + let proxy = Proxy::new(Intercept::All, uri); + let connector = HttpsConnector::new(); + let proxy_connector = ProxyConnector::from_proxy(connector, proxy).unwrap(); + let client = Client::builder(TokioExecutor::new()).build::<_, B>(proxy_connector); + client.request(req).await + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 00000000..ead36137 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,22 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +mod client; +pub use client::*; + +mod release; +pub use release::*; + +mod price; +pub use price::grin_rate; diff --git a/src/http/price.rs b/src/http/price.rs new file mode 100644 index 00000000..8cbfd57a --- /dev/null +++ b/src/http/price.rs @@ -0,0 +1,117 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! GRIN price preview, cached per currency. Off by default (no pairing → no +//! fetch); only once the user opts into a pairing is the rate fetched — and it +//! goes over the Nym mixnet like everything else, never the clear net. The rate +//! barely moves and CoinGecko's free tier rate-limits frequent polling (the +//! shared exit IP all the more), so it refreshes on a slow cache, not live. + +use lazy_static::lazy_static; +use parking_lot::RwLock; +use std::collections::{HashMap, HashSet}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::nym; + +/// Cache refresh interval (seconds). +const REFRESH_SECS: i64 = 300; + +/// Minimum delay between fetch attempts for a currency, so a failing fetch +/// (e.g. no network) does not respawn a thread every frame. +const RETRY_SECS: i64 = 30; + +lazy_static! { + /// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at). + static ref RATES: RwLock> = RwLock::new(HashMap::new()); + /// Currencies with a fetch currently in flight. + static ref FETCHING: RwLock> = RwLock::new(HashSet::new()); + /// Last fetch attempt per currency (unix secs). + static ref LAST_TRY: RwLock> = RwLock::new(HashMap::new()); +} + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Get the cached GRIN rate against `vs` (e.g. "usd", "eur", "btc") if fresh, +/// triggering a background refresh otherwise. Returns `None` until the first +/// successful fetch for that currency. +pub fn grin_rate(vs: &str) -> Option { + let cached = { RATES.read().get(vs).cloned() }; + let needs_refresh = match cached { + Some((_, ts)) => now() - ts > REFRESH_SECS, + None => true, + }; + if needs_refresh { + trigger_refresh(vs.to_string()); + } + cached.map(|(rate, _)| rate) +} + +/// Spawn a background refresh for one currency (deduped per code). +fn trigger_refresh(vs: String) { + let t = now(); + { + let last = LAST_TRY.read().get(&vs).copied().unwrap_or(0); + if t - last < RETRY_SECS { + return; + } + } + { + let mut fetching = FETCHING.write(); + if fetching.contains(&vs) { + return; + } + fetching.insert(vs.clone()); + } + LAST_TRY.write().insert(vs.clone(), t); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + if let Some(rate) = fetch_rate(&vs).await { + RATES.write().insert(vs.clone(), (rate, now())); + } + }); + FETCHING.write().remove(&vs); + }); +} + +/// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet. +async fn fetch_rate(vs: &str) -> Option { + let url = format!( + "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies={}", + vs + ); + // CoinGecko rejects requests without a User-Agent (403). A static, + // non-identifying UA is fine over the mixnet. + let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())]; + let body = nym::http_request("GET", url, None, headers).await?; + let parsed: Option = serde_json::from_str::(&body) + .ok() + .and_then(|doc| doc.get("grin")?.get(vs)?.as_f64()); + if parsed.is_none() { + log::warn!( + "price: unexpected response from rate API: {}", + body.chars().take(120).collect::() + ); + } + parsed +} diff --git a/src/http/release.rs b/src/http/release.rs new file mode 100644 index 00000000..b32d1997 --- /dev/null +++ b/src/http/release.rs @@ -0,0 +1,161 @@ +// Copyright 2026 The Grim Developers +// +// 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. + +use crate::gui::views::View; +use crate::http::HttpClient; +use bytes::Bytes; +use chrono::NaiveDateTime; +use egui::os::OperatingSystem; +use http_body_util::{BodyExt, Empty}; +use serde_derive::Deserialize; + +#[derive(Deserialize)] +pub struct ReleaseAsset { + pub name: String, + pub browser_download_url: String, + pub size: u64, +} + +#[derive(Deserialize)] +pub struct ReleaseInfo { + pub tag_name: String, + pub body: String, + pub published_at: String, + pub assets: Vec, +} + +#[cfg(target_arch = "x86_64")] +/// x86 CPU architecture. +const X86_ARCH: &str = "x86_64"; +#[cfg(target_arch = "x86_64")] +const ARCH: &'static str = X86_ARCH; + +/// ARM CPU architecture. +const ARM_ARCH: &str = "arm"; +#[cfg(any(target_arch = "arm", target_arch = "aarch64"))] +const ARCH: &'static str = ARM_ARCH; + +/// Base endpoint to download the release. +const BASE_DOWNLOAD_URL: &'static str = "https://github.com/2ro/goblin/releases/download/"; + +impl ReleaseInfo { + /// Release version (the build tag, e.g. "build71"). + pub fn version(&self) -> String { + self.tag_name.clone() + } + + /// Get artifact release name based on current platform. Matches the assets + /// attached to Goblin's GitHub releases; platforms Goblin doesn't ship + /// (linux-arm, macOS, windows-arm) return None. + fn name(&self) -> Option { + let os = OperatingSystem::from_target_os(); + match os { + OperatingSystem::Unknown => None, + OperatingSystem::Android => { + let name = if ARCH == ARM_ARCH { + format!("goblin-{}-android-arm.apk", self.tag_name) + } else { + format!("goblin-{}-android-x86_64.apk", self.tag_name) + }; + Some(name) + } + OperatingSystem::IOS => None, + OperatingSystem::Nix => { + if ARCH == ARM_ARCH { + None + } else { + Some(format!("goblin-{}-linux-x86_64.AppImage", self.tag_name)) + } + } + OperatingSystem::Mac => None, + OperatingSystem::Windows => { + if ARCH == ARM_ARCH { + None + } else { + Some(format!("goblin-{}-win-x86_64.zip", self.tag_name)) + } + } + } + } + + /// Get link to download the release. + pub fn url(&self) -> Option { + let base_url = format!("{}{}/", BASE_DOWNLOAD_URL, self.tag_name); + if let Some(name) = self.name() { + return Some(format!("{}{}", base_url, name)); + } + None + } + + /// Get formatted release date. + pub fn date(&self) -> String { + let date = self.published_at.clone().replace("T", " ").replace("Z", ""); + let date_format = NaiveDateTime::parse_from_str(date.as_str(), "%Y-%m-%d %H:%M:%S"); + if let Ok(date) = date_format { + return View::format_time(date.and_utc().timestamp()); + } + date + } + + /// Get release size in megabytes. + pub fn size(&self) -> Option { + let name = self.name()?; + for a in &self.assets { + if a.name == name { + let size_mb = a.size as f64 / 1000000.0; + return Some(format!("{:.2}", size_mb)); + } + } + None + } + + /// Whether this release is newer than the running build. Goblin versions by + /// build number ("buildNN" tags) rather than semver, so compare the numbers. + pub fn is_update(&self) -> bool { + let cur: u64 = crate::BUILD.trim().parse().unwrap_or(0); + let rel: u64 = self + .tag_name + .trim() + .trim_start_matches("build") + .parse() + .unwrap_or(0); + rel > cur + } +} + +/// API endpoint to check last release (Goblin's own GitHub releases). +const REQUEST_URL: &'static str = "https://api.github.com/repos/2ro/goblin/releases/latest"; + +pub async fn retrieve_release() -> Result { + let req = hyper::Request::builder() + .method(hyper::Method::GET) + .uri(REQUEST_URL) + // GitHub's API rejects requests without a User-Agent. + .header("User-Agent", "goblin-wallet") + .header("Accept", "application/vnd.github+json") + .body(Empty::::new()) + .unwrap(); + if let Ok(resp) = HttpClient::send(req).await { + let status = resp.status().as_u16(); + if status == 200 { + if let Ok(body) = resp.into_body().collect().await { + let body_bytes = body.to_bytes(); + if let Ok(update_info) = serde_json::from_slice::(&body_bytes) { + return Ok(update_info); + } + } + } + } + Err("Error checking update".to_string()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..e4073a2a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,440 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +#[macro_use] +extern crate rust_i18n; +rust_i18n::i18n!("locales"); + +use eframe::NativeOptions; +use egui::{Context, Stroke, Theme}; +use lazy_static::lazy_static; +use parking_lot::RwLock; +use std::sync::Arc; + +#[cfg(target_os = "android")] +use winit::platform::android::activity::AndroidApp; + +pub use settings::AppConfig; +pub use settings::Settings; + +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::View; +use crate::gui::{App, Colors}; +use crate::node::Node; + +pub mod gui; +mod http; +pub mod logger; +mod node; +pub mod nostr; +mod nym; +mod settings; +mod wallet; + +/// Upstream GRIM version the fork is based on (third-party credit). +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Goblin build number: commits on top of the GRIM base (see build.rs). +pub const BUILD: &str = env!("GOBLIN_BUILD"); + +/// Android platform entry point. +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[unsafe(no_mangle)] +fn android_main(app: AndroidApp) { + // Setup logger. + logger::init_logger(); + + use gui::platform::Android; + let platform = Android::new(app.clone()); + use winit::platform::android::EventLoopBuilderExtAndroid; + + // Setup system theme if not set. + if let None = AppConfig::dark_theme() { + let use_dark = use_dark_theme(&platform); + AppConfig::set_dark_theme(use_dark); + } + + let width = app.config().screen_width_dp().unwrap() as f32; + let height = app.config().screen_height_dp().unwrap() as f32; + let size = egui::emath::vec2(width, height); + let mut options = NativeOptions { + android_app: Some(app.clone()), + viewport: egui::ViewportBuilder::default().with_inner_size(size), + ..Default::default() + }; + options.event_loop_builder = Some(Box::new(move |builder| { + builder.with_android_app(app); + })); + + let app = App::new(platform); + start(options, app_creator(app)).unwrap(); +} + +/// Check if system is using dark theme. +#[allow(dead_code)] +#[cfg(target_os = "android")] +fn use_dark_theme(platform: &gui::platform::Android) -> bool { + let res = platform + .call_java_method("useDarkTheme", "()Z", &[]) + .unwrap(); + unsafe { res.z != 0 } +} + +/// [`App`] setup for [`eframe`]. +pub fn app_creator(app: App) -> eframe::AppCreator<'static> +where + App: eframe::App, + T: PlatformCallbacks, +{ + Box::new(|cc| { + // Setup images support. + egui_extras::install_image_loaders(&cc.egui_ctx); + // Bind fonts before the first frame: set_fonts inside a frame only + // applies on the next pass, and the first-run onboarding references + // named weight families (Geist) on frame one. + setup_fonts(&cc.egui_ctx); + Ok(Box::new(app)) + }) +} + +/// Entry point to start ui with [`eframe`]. +pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe::Result<()> { + // Pin rustls to the ring provider process-wide. Linking nym-sdk brings + // aws-lc-rs into the graph alongside our ring; with two providers present + // rustls 0.23 won't auto-select a default, and tokio-tungstenite/reqwest + // would panic on the first TLS handshake. nym uses its own explicit provider, + // so this only steers our relay/HTTP TLS. Idempotent (Err if already set). + let _ = rustls::crypto::ring::default_provider().install_default(); + // Setup translations. + setup_i18n(); + // Start integrated node if needed. + if AppConfig::autostart_node() { + Node::start(); + } + // Pre-warm the in-process Nym mixnet client so price/NIP-05/nostr are ready at + // first use. All of Goblin's outbound traffic egresses through it; nothing + // clearnet. + nym::warm_up(); + // Launch graphical interface. + eframe::run_native("Goblin", options, app_creator) +} + +/// Setup application [`egui::Style`] and [`egui::Visuals`]. +pub fn setup_visuals(ctx: &Context) { + let use_dark = AppConfig::dark_theme().unwrap_or_else(|| { + let use_dark = ctx.system_theme().unwrap_or(Theme::Dark) == Theme::Dark; + AppConfig::set_dark_theme(use_dark); + use_dark + }); + + let mut style = (*ctx.style()).clone(); + // Setup selection. + style.interaction.selectable_labels = false; + style.interaction.multi_widget_text_select = false; + // Setup spacing for buttons. + if View::is_desktop() { + style.spacing.button_padding = egui::vec2(12.0, 8.0); + } else { + style.spacing.button_padding = egui::vec2(14.0, 10.0); + } + // Make scroll-bar thinner and lighter. + style.spacing.scroll.bar_width = 4.0; + style.spacing.scroll.bar_outer_margin = -2.0; + style.spacing.scroll.foreground_color = false; + // Disable spacing between items. + style.spacing.item_spacing = egui::vec2(0.0, 0.0); + style.spacing.text_edit_width = 500.0; + // Setup radio button/checkbox size and spacing. + style.spacing.icon_width = 24.0; + style.spacing.icon_width_inner = 14.0; + style.spacing.icon_spacing = 10.0; + // Setup style + ctx.set_style(style); + + // Setup visuals based on the Goblin theme tokens. + let _ = use_dark; + let t = gui::theme::tokens(); + let mut visuals = if t.dark_base { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }; + // Base surfaces. + visuals.panel_fill = t.bg; + visuals.window_fill = t.surface; + visuals.extreme_bg_color = t.surface2; + visuals.faint_bg_color = t.surface2; + // Default text inks. + visuals.widgets.noninteractive.fg_stroke.color = t.text_dim; + visuals.widgets.hovered.fg_stroke.color = t.text; + visuals.widgets.active.fg_stroke.color = t.text; + // Setup selection color. + visuals.selection.stroke = Stroke { + width: 1.0, + color: t.accent_ink, + }; + visuals.selection.bg_fill = t.accent; + // Disable stroke around panels by default. + visuals.widgets.noninteractive.bg_stroke = Stroke::NONE; + // Setup stroke around inactive widgets. + visuals.widgets.inactive.bg_stroke = View::default_stroke(); + // Setup background and foreground stroke color for widgets like pull-to-refresher. + visuals.widgets.inactive.bg_fill = if t.dark_base { t.bg } else { t.accent }; + visuals.widgets.inactive.fg_stroke.color = Colors::item_button_text(); + // Hover/active fills. + visuals.widgets.hovered.bg_fill = t.hover; + visuals.widgets.active.bg_fill = t.hover; + // Setup visuals. + ctx.set_visuals(visuals); +} + +/// Setup application fonts: Geist (+ weight families), Geist Mono, +/// Phosphor icons and Noto SC as CJK/ツ fallback. +pub fn setup_fonts(ctx: &Context) { + use egui::FontFamily::{Monospace, Proportional}; + + let mut fonts = egui::FontDefinitions::default(); + + let plain = |bytes: &'static [u8]| Arc::new(egui::FontData::from_static(bytes)); + fonts.font_data.insert( + "geist".to_owned(), + plain(include_bytes!("../fonts/Geist-Regular.ttf")), + ); + fonts.font_data.insert( + "geist-medium".to_owned(), + plain(include_bytes!("../fonts/Geist-Medium.ttf")), + ); + fonts.font_data.insert( + "geist-semibold".to_owned(), + plain(include_bytes!("../fonts/Geist-SemiBold.ttf")), + ); + fonts.font_data.insert( + "geist-bold".to_owned(), + plain(include_bytes!("../fonts/Geist-Bold.ttf")), + ); + fonts.font_data.insert( + "geist-mono".to_owned(), + plain(include_bytes!("../fonts/GeistMono-Regular.ttf")), + ); + fonts.font_data.insert( + "geist-mono-sb".to_owned(), + plain(include_bytes!("../fonts/GeistMono-SemiBold.ttf")), + ); + fonts.font_data.insert( + "phosphor".to_owned(), + Arc::new( + egui::FontData::from_static(include_bytes!("../fonts/phosphor.ttf")).tweak( + egui::FontTweak { + scale: 1.0, + y_offset_factor: -0.04, + y_offset: 0.0, + }, + ), + ), + ); + fonts.font_data.insert( + "noto".to_owned(), + Arc::new( + egui::FontData::from_static(include_bytes!("../fonts/noto_sc_reg.otf")).tweak( + egui::FontTweak { + scale: 1.0, + y_offset_factor: -0.08, + y_offset: 0.0, + }, + ), + ), + ); + // Noto Sans JP subset — ONLY the ツ glyph (~1.7 KB), the mark on the center + // Pay puck. A clean, geometric katakana tsu; referenced solely at that widget. + fonts.font_data.insert( + "noto-tsu".to_owned(), + plain(include_bytes!("../fonts/NotoSansJpTsu.otf")), + ); + + // Default proportional stack: Geist first, icons and CJK/ツ as fallback. + { + let prop = fonts.families.entry(Proportional).or_default(); + prop.insert(0, "geist".to_owned()); + prop.insert(1, "phosphor".to_owned()); + prop.insert(2, "noto".to_owned()); + } + // Monospace stack for amounts (tabular digits). + { + let mono = fonts.families.entry(Monospace).or_default(); + mono.insert(0, "geist-mono".to_owned()); + mono.insert(1, "phosphor".to_owned()); + mono.insert(2, "noto".to_owned()); + } + // Named weight families, each with icon + CJK fallback. + for name in [ + "geist-medium", + "geist-semibold", + "geist-bold", + "geist-mono-sb", + ] { + fonts.families.insert( + egui::FontFamily::Name(name.into()), + vec![name.to_owned(), "phosphor".to_owned(), "noto".to_owned()], + ); + } + // Puck ツ family: the subset first, then the normal fallbacks so anything + // other than ツ still renders (the puck only ever draws ツ with it). + fonts.families.insert( + egui::FontFamily::Name("noto-tsu".into()), + vec![ + "noto-tsu".to_owned(), + "geist-bold".to_owned(), + "noto".to_owned(), + ], + ); + + ctx.set_fonts(fonts); + + use egui::FontId; + use egui::TextStyle; + + // NOTE: text_styles must only reference Proportional/Monospace families. + // set_fonts() applies on the next pass while set_style() is immediate; a + // default text style referencing a custom Name family would panic on the + // first frame before the fonts swap in. Goblin weights are applied at the + // widget call sites via RichText::font(), which render after the swap. + let mut style = (*ctx.style()).clone(); + style.text_styles = [ + (TextStyle::Heading, FontId::new(19.0, Proportional)), + (TextStyle::Body, FontId::new(16.0, Proportional)), + (TextStyle::Button, FontId::new(17.0, Proportional)), + (TextStyle::Small, FontId::new(15.0, Proportional)), + ( + TextStyle::Monospace, + FontId::new(16.0, egui::FontFamily::Monospace), + ), + ] + .into(); + + ctx.set_style(style); +} + +/// Setup translations. +fn setup_i18n() { + // Set saved locale or get from system. + if let Some(lang) = AppConfig::locale() { + if rust_i18n::available_locales!().contains(&lang.as_str()) { + rust_i18n::set_locale(lang.as_str()); + } + } else { + let locale = sys_locale::get_locale().unwrap_or(String::from(AppConfig::DEFAULT_LOCALE)); + // sys_locale may hand back either `zh-CN` or `zh_CN`; normalize the + // separator so a region-specific locale can match its file name. + let normalized = locale.replace('_', "-"); + let available = rust_i18n::available_locales!(); + // Prefer an exact region match (e.g. `zh-CN`, the only CJK locale and one + // the bare-subtag fallback could never reach), then the language subtag + // (e.g. `de` from `de-DE`), else the default. + let primary = normalized + .split('-') + .next() + .unwrap_or(AppConfig::DEFAULT_LOCALE); + if available.contains(&normalized.as_str()) { + rust_i18n::set_locale(normalized.as_str()); + } else if available.contains(&primary) { + rust_i18n::set_locale(primary); + } else { + rust_i18n::set_locale(AppConfig::DEFAULT_LOCALE); + } + } +} + +/// Get data from deeplink or opened file. +pub fn consume_incoming_data() -> Option { + let has_data = { + let r_data = INCOMING_DATA.read(); + r_data.is_some() + }; + if has_data { + // Clear data. + let mut w_data = INCOMING_DATA.write(); + let data = w_data.clone(); + *w_data = None; + return data; + } + None +} + +/// Provide data from deeplink or opened file. +pub fn on_data(data: String) { + let mut w_data = INCOMING_DATA.write(); + *w_data = Some(data); +} + +/// Unix-seconds timestamp of the most recent GUI frame. Background workers read +/// it to tell whether the app is actually on-screen: while the app is +/// backgrounded, eframe stops calling the per-frame draw and this stops +/// advancing. Crate-root so both `gui` and `nostr` can reach it without coupling. +static LAST_FRAME_AT: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); + +/// A frame older than this many seconds means the app isn't drawing — i.e. it's +/// backgrounded/occluded. The GUI keeps a ~2s repaint heartbeat while visible, so +/// this leaves a couple of frames of margin before declaring "not foreground". +const FOREGROUND_STALE_SECS: i64 = 5; + +fn now_unix_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Stamp that the GUI just drew a frame. Called once per frame from the app loop. +pub fn mark_frame() { + LAST_FRAME_AT.store(now_unix_secs(), std::sync::atomic::Ordering::Relaxed); +} + +/// True when the GUI drew a frame within the last few seconds — i.e. the app is +/// foreground and visible. While backgrounded (no frames), returns false, so +/// periodic background work (the @name re-verify sweep) can pause and catch up +/// on resume instead of burning mixnet round-trips while nobody's looking. +pub fn app_foreground() -> bool { + let last = LAST_FRAME_AT.load(std::sync::atomic::Ordering::Relaxed); + last != 0 && now_unix_secs() - last <= FOREGROUND_STALE_SECS +} + +lazy_static! { + /// Data provided from deeplink or opened file. + pub static ref INCOMING_DATA: Arc>> = Arc::new(RwLock::new(None)); +} + +/// Callback from Java code with passed data. +#[allow(dead_code)] +#[allow(non_snake_case)] +#[cfg(target_os = "android")] +#[unsafe(no_mangle)] +pub extern "C" fn Java_mw_gri_android_MainActivity_onData( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + char: jni::sys::jstring, +) { + unsafe { + let j_obj = jni::objects::JString::from_raw(char); + if let Ok(j_str) = _env.get_string_unchecked(j_obj.as_ref()) { + match j_str.to_str() { + Ok(str) => { + let mut w_path = INCOMING_DATA.write(); + *w_path = Some(str.to_string()); + } + Err(_) => {} + } + }; + } +} diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 00000000..c55efee6 --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,165 @@ +// Copyright 2026 The Grim Developers +// +// 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. + +use backtrace::Backtrace; +use log::{LevelFilter, Record, error}; +use log4rs::Config; +use log4rs::append::Append; +use log4rs::append::console::ConsoleAppender; +use log4rs::append::rolling_file::RollingFileAppender; +use log4rs::append::rolling_file::policy::compound::CompoundPolicy; +use log4rs::append::rolling_file::policy::compound::roll::fixed_window::FixedWindowRoller; +use log4rs::append::rolling_file::policy::compound::trigger::size::SizeTrigger; +use log4rs::config::{Appender, Root}; +use log4rs::encode::pattern::PatternEncoder; +use log4rs::filter::threshold::ThresholdFilter; +use log4rs::filter::{Filter, Response}; +use std::fs::File; +use std::{panic, thread}; + +use crate::Settings; + +const LOGGING_PATTERN: &str = "{d(%Y%m%d %H:%M:%S%.3f)} {h({l})} {M} - {m}{n}"; + +/// 32 log files to rotate over by default. +const ROTATE_LOG_FILES: u32 = 32; +/// Size of the log in bytes to rotate over (6 megabytes). +const MAX_FILE_SIZE: u64 = 1024 * 1024 * 6; + +/// Include build information. +pub mod built_info { + include!(concat!(env!("OUT_DIR"), "/built.rs")); +} + +/// Filter is rejecting messages that doesn't start with "grin" or "grim" +#[derive(Debug)] +struct AppFilter; + +impl Filter for AppFilter { + fn filter(&self, record: &Record<'_>) -> Response { + if let Some(module_path) = record.module_path() { + if module_path.starts_with("grin") || module_path.starts_with("grim") { + return Response::Neutral; + } + } + Response::Reject + } +} + +/// Initialize the logger. +pub fn init_logger() { + let stdout = ConsoleAppender::builder() + .encoder(Box::new(PatternEncoder::new(&LOGGING_PATTERN))) + .build(); + + let mut root = Root::builder(); + + let level_filter = Box::new(ThresholdFilter::new(LevelFilter::Debug)); + + let mut app = vec![]; + app.push( + Appender::builder() + .filter(level_filter.clone()) + .filter(Box::new(AppFilter)) + .build("stdout", Box::new(stdout)), + ); + root = root.appender("stdout"); + + // Setup file logging. + let file: Box = { + let path = Settings::log_path(); + let roller = FixedWindowRoller::builder() + .build(&format!("{}.{{}}.gz", path), ROTATE_LOG_FILES) + .unwrap(); + let trigger = SizeTrigger::new(MAX_FILE_SIZE); + let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller)); + Box::new( + RollingFileAppender::builder() + .append(true) + .encoder(Box::new(PatternEncoder::new(&LOGGING_PATTERN))) + .build(path, Box::new(policy)) + .expect("Failed to create logfile"), + ) + }; + app.push( + Appender::builder() + .filter(level_filter) + .filter(Box::new(AppFilter)) + .build("file", file), + ); + root = root.appender("file"); + + let config = Config::builder() + .appenders(app) + .build(root.build(LevelFilter::Debug)) + .unwrap(); + let _ = log4rs::init_config(config).unwrap(); + + log::info!("{}", build_info()); + + send_panic_to_log(); +} + +/// Get information about application build. +fn build_info() -> String { + format!( + "This is Grim version {}, built for {} by {}.", + built_info::PKG_VERSION, + built_info::TARGET, + built_info::RUSTC_VERSION, + ) +} + +/// Hook to send panics to logs as well as stderr. +fn send_panic_to_log() { + panic::set_hook(Box::new(|info| { + let backtrace = Backtrace::new(); + + let thread = thread::current(); + let thread = thread.name().unwrap_or("unnamed"); + + let msg = match info.payload().downcast_ref::<&'static str>() { + Some(s) => *s, + None => match info.payload().downcast_ref::() { + Some(s) => &**s, + None => "Box", + }, + }; + + match info.location() { + Some(location) => { + error!( + "{}\nThread '{}' panicked at '{}': {}:{}{:?}\n\n", + build_info(), + thread, + msg, + location.file(), + location.line(), + backtrace + ); + } + None => error!("Thread '{}' panicked at '{}'{:?}", thread, msg, backtrace), + } + // Also print to stderr. + eprintln!( + "Thread '{}' panicked with message:\n\"{}\"\nSee {} for further details.", + thread, + msg, + Settings::log_path() + ); + // Create file to show report send on launch. + let log = Settings::crash_check_path(); + let _ = File::create(log); + })); +} diff --git a/src/main.rs b/src/main.rs new file mode 100755 index 00000000..8a725ffe --- /dev/null +++ b/src/main.rs @@ -0,0 +1,241 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +#![windows_subsystem = "windows"] + +pub fn main() { + #[allow(dead_code)] + #[cfg(not(target_os = "android"))] + real_main(); +} + +#[allow(dead_code)] +#[cfg(not(target_os = "android"))] +fn real_main() { + // Initialize logger. + grim::logger::init_logger(); + // Handle file path argument passing. + let args: Vec<_> = std::env::args().collect(); + let mut data = None; + if args.len() > 1 { + let path = std::path::PathBuf::from(&args[1]); + let content = match std::fs::read_to_string(path) { + Ok(s) => Some(s), + Err(_) => Some(args[1].clone()), + }; + data = content + } + + // Start GUI. + if is_app_running(&data) { + return; + } else if let Some(data) = data { + grim::on_data(data); + } + let platform = grim::gui::platform::Desktop::new(); + start_app_socket(platform.clone()); + start_desktop_gui(platform); +} + +/// Get panic message from crash payload. +#[allow(dead_code)] +#[cfg(not(target_os = "android"))] +fn panic_info_message<'pi>(panic_info: &'pi std::panic::PanicHookInfo<'_>) -> &'pi str { + let payload = panic_info.payload(); + // taken from: https://github.com/rust-lang/rust/blob/4b9f4b221b92193c7e95b1beb502c6eb32c3b613/library/std/src/panicking.rs#L194-L200 + match payload.downcast_ref::<&'static str>() { + Some(msg) => *msg, + None => match payload.downcast_ref::() { + Some(msg) => msg.as_str(), + // Copy what rustc does in the default panic handler + None => "Box", + }, + } +} + +/// Start GUI with Desktop related setup passing data from opening. +#[allow(dead_code)] +#[cfg(not(target_os = "android"))] +fn start_desktop_gui(platform: grim::gui::platform::Desktop) { + use grim::AppConfig; + let os = egui::os::OperatingSystem::from_target_os(); + let (width, height) = AppConfig::window_size(); + let mut viewport = egui::ViewportBuilder::default() + .with_min_inner_size([AppConfig::MIN_WIDTH, AppConfig::MIN_HEIGHT]) + .with_inner_size([width, height]); + + // Setup icon. + if let Ok(icon) = eframe::icon_data::from_png_bytes(include_bytes!("../img/icon.png")) { + viewport = viewport.with_icon(std::sync::Arc::new(icon)); + } + // Setup window position. + if let Some((x, y)) = AppConfig::window_pos() { + viewport = viewport.with_position(egui::pos2(x, y)); + } + // Setup window decorations. + let is_mac = os == egui::os::OperatingSystem::Mac; + let is_win = os == egui::os::OperatingSystem::Windows; + viewport = viewport + // Wayland taskbars resolve the icon through the .desktop file whose + // name matches this app id (see linux/Goblin.AppDir/goblin.desktop). + .with_app_id("goblin") + .with_fullsize_content_view(true) + .with_window_level(egui::WindowLevel::Normal) + .with_title_shown(is_win) + .with_titlebar_buttons_shown(is_win) + .with_titlebar_shown(is_win) + .with_transparent(true) + .with_decorations(is_mac || is_win); + + let mut options = eframe::NativeOptions { + renderer: eframe::Renderer::Glow, + viewport, + ..Default::default() + }; + + // Start GUI. + let app = grim::gui::App::new(platform.clone()); + match grim::start(options.clone(), grim::app_creator(app)) { + Ok(_) => {} + Err(_) => { + // Start with another renderer on error. + options.renderer = eframe::Renderer::Wgpu; + + let app = grim::gui::App::new(platform); + match grim::start(options, grim::app_creator(app)) { + Ok(_) => {} + Err(e) => { + panic!("{}", e); + } + } + } + } +} + +/// Check if application is already running to pass data. +#[allow(dead_code)] +#[cfg(not(target_os = "android"))] +fn is_app_running(data: &Option) -> bool { + let res: Result<(), Box> = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + use interprocess::local_socket::tokio::{Stream, prelude::*}; + use tokio::io::AsyncWriteExt; + + let socket_path = grim::Settings::socket_path(); + let name = socket_name(&socket_path)?; + + // Connect to running application socket. + let conn = Stream::connect(name).await?; + let data = data.clone().unwrap_or("".to_string()); + if data.is_empty() { + return Ok(()); + } + let (rec, mut sen) = conn.split(); + + // Send data to socket. + let _ = sen.write_all(data.as_bytes()).await; + + drop((rec, sen)); + Ok(()) + }); + match res { + Ok(_) => true, + Err(_) => false, + } +} + +/// Start desktop socket that handles data for single application instance. +#[allow(dead_code)] +#[cfg(not(target_os = "android"))] +fn start_app_socket(platform: grim::gui::platform::Desktop) { + std::thread::spawn(move || { + let _ = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + use grim::gui::platform::PlatformCallbacks; + use interprocess::local_socket::{ + Listener, ListenerOptions, + tokio::{Stream, prelude::*}, + }; + use std::io; + use tokio::io::{AsyncBufReadExt, BufReader}; + + // Handle incoming connection. + async fn handle_conn(conn: Stream) -> io::Result { + let mut read = BufReader::new(&conn); + let mut buffer = String::new(); + // Read data. + let _ = read.read_line(&mut buffer).await; + Ok(buffer) + } + + // Setup socket name. + let socket_path = grim::Settings::socket_path(); + if socket_path.exists() { + let _ = std::fs::remove_file(&socket_path); + } + let name = socket_name(&socket_path)?; + + // Create listener. + let opts = ListenerOptions::new().name(name); + let listener = match opts.create_tokio() { + Err(e) if e.kind() == io::ErrorKind::AddrInUse => { + log::error!("Socket file is occupied."); + return Err::(e); + } + x => x?, + }; + + loop { + let conn = match listener.accept().await { + Ok(c) => c, + Err(e) => { + log::error!("{:?}", e); + continue; + } + }; + // Handle connection. + let res = handle_conn(conn).await; + match res { + Ok(data) => { + grim::on_data(data); + platform.request_user_attention(); + } + Err(_) => {} + } + } + }); + }); +} + +/// Get application socket name from provided path. +#[allow(dead_code)] +#[cfg(not(target_os = "android"))] +fn socket_name(path: &std::path::PathBuf) -> std::io::Result> { + use interprocess::local_socket::{NameType, ToFsName, ToNsName}; + let name = if egui::os::OperatingSystem::Mac != egui::os::OperatingSystem::from_target_os() + && interprocess::local_socket::GenericNamespaced::is_supported() + { + grim::Settings::SOCKET_NAME.to_ns_name::()? + } else { + path.clone() + .to_fs_name::()? + }; + Ok(name) +} diff --git a/src/node/config.rs b/src/node/config.rs new file mode 100644 index 00000000..04286c5a --- /dev/null +++ b/src/node/config.rs @@ -0,0 +1,1059 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use local_ip_address::list_afinet_netifas; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::{BufRead, BufReader, Write}; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener, ToSocketAddrs}; +use std::path::PathBuf; +use std::str::FromStr; + +use grin_config::config::{ + API_SECRET_FILE_NAME, FOREIGN_API_SECRET_FILE_NAME, SERVER_CONFIG_FILE_NAME, +}; +use grin_config::{ConfigError, ConfigMembers, GlobalConfig, config}; +use grin_core::global::ChainTypes; +use grin_p2p::msg::PeerAddrs; +use grin_p2p::{PeerAddr, Seeding}; +use grin_servers::common::types::ChainValidationMode; +use rand::Rng; + +use crate::node::Node; +use crate::{AppConfig, Settings}; + +/// Peers config to save peers DNS names into the file. +#[derive(Serialize, Deserialize, Default)] +pub struct PeersConfig { + seeds: Vec, + allowed: Vec, + denied: Vec, + preferred: Vec, +} + +impl PeersConfig { + /// File name for peers config. + pub const FILE_NAME: &'static str = "peers.toml"; + + /// Save peers config to the file. + pub fn save(&self) { + let chain_type = AppConfig::chain_type(); + let config_path = Settings::config_path(Self::FILE_NAME, Some(chain_type.shortname())); + Settings::write_to_file(self, config_path); + } + + /// Convert string to [`PeerAddr`] if address is in correct format (`host:port`) and available. + pub fn peer_to_addr(peer: String) -> Option { + match SocketAddr::from_str(peer.as_str()) { + // Try to parse IP address first. + Ok(ip) => Some(PeerAddr(ip)), + // If that fails it's probably a DNS record. + Err(_) => { + if let Ok(mut socket_addr_list) = peer.to_socket_addrs() { + if let Some(addr) = socket_addr_list.next() { + return Some(PeerAddr(addr)); + } + } + None + } + } + } + + /// Load saved peers to node server [`ConfigMembers`] config. + pub fn load_to_server_config(config: &mut ConfigMembers) { + // Load seeds. + let r_config = Settings::node_config_to_read(); + for seed in r_config.peers.seeds.clone() { + if let Some(p) = Self::peer_to_addr(seed.to_string()) { + let mut seeds = config + .server + .p2p_config + .seeds + .clone() + .unwrap_or(PeerAddrs::default()); + seeds.peers.insert(seeds.peers.len(), p); + config.server.p2p_config.seeds = Some(seeds); + } + } + // Load allowed peers. + for peer in r_config.peers.allowed.clone() { + if let Some(p) = Self::peer_to_addr(peer.clone()) { + let mut allowed = config + .server + .p2p_config + .peers_allow + .clone() + .unwrap_or(PeerAddrs::default()); + allowed.peers.insert(allowed.peers.len(), p); + config.server.p2p_config.peers_allow = Some(allowed); + } + } + // Load denied peers. + for peer in r_config.peers.denied.clone() { + if let Some(p) = Self::peer_to_addr(peer.clone()) { + let mut denied = config + .server + .p2p_config + .peers_deny + .clone() + .unwrap_or(PeerAddrs::default()); + denied.peers.insert(denied.peers.len(), p); + config.server.p2p_config.peers_deny = Some(denied); + } + } + // Load preferred peers. + for peer in &r_config.peers.preferred.clone() { + if let Some(p) = Self::peer_to_addr(peer.clone()) { + let mut preferred = config + .server + .p2p_config + .peers_preferred + .clone() + .unwrap_or(PeerAddrs::default()); + preferred.peers.insert(preferred.peers.len(), p); + config.server.p2p_config.peers_preferred = Some(preferred); + } + } + } +} + +/// Wrapped node config to be used by [`grin_servers::Server`]. +#[derive(Serialize, Deserialize)] +pub struct NodeConfig { + pub(crate) node: ConfigMembers, + pub(crate) peers: PeersConfig, +} + +impl NodeConfig { + /// Initialize config fields from provided [`ChainTypes`]. + pub fn for_chain_type(chain_type: &ChainTypes) -> Self { + // Check secret files for current chain type. + let _ = Self::check_api_secret_files(chain_type, API_SECRET_FILE_NAME); + let _ = Self::check_api_secret_files(chain_type, FOREIGN_API_SECRET_FILE_NAME); + + // Initialize peers config. + let peers_config = { + let sub_dir = Some(chain_type.shortname()); + let path = Settings::config_path(PeersConfig::FILE_NAME, sub_dir); + let config = Settings::read_from_file::(path.clone()); + if !path.exists() || config.is_err() { + Self::save_default_peers_config(chain_type) + } else { + config.unwrap() + } + }; + + // Initialize node config. + let node_config = { + let sub_dir = Some(chain_type.shortname()); + let path = Settings::config_path(SERVER_CONFIG_FILE_NAME, sub_dir); + let config = Settings::read_from_file::(path.clone()); + if !path.exists() || config.is_err() { + Self::save_default_node_server_config(chain_type) + } else { + config.unwrap() + } + }; + + Self { + node: node_config, + peers: peers_config, + } + } + + /// Save default node config for specified [`ChainTypes`]. + fn save_default_node_server_config(chain_type: &ChainTypes) -> ConfigMembers { + let sub_dir = Some(chain_type.shortname()); + let path = Settings::config_path(SERVER_CONFIG_FILE_NAME, sub_dir.clone()); + + let mut default_config = GlobalConfig::for_chain(chain_type); + default_config.update_paths(&Settings::base_path(sub_dir)); + let mut config = default_config.members.unwrap(); + + // Generate random p2p and api ports. + Self::setup_default_ports(&mut config); + + // Clear wallet listener url (actually it will be wallet id). + config + .server + .stratum_mining_config + .clone() + .unwrap() + .wallet_listener_url = "".to_string(); + + Settings::write_to_file(&config, path); + config + } + + /// Generate random p2p and api ports in ranges based on [`ChainTypes`]. + fn setup_default_ports(config: &mut ConfigMembers) { + let (api, p2p) = match config.server.chain_type { + ChainTypes::Mainnet => { + let api = rand::rng().random_range(30000..33000); + let p2p = rand::rng().random_range(33000..37000); + (api, p2p) + } + _ => { + let api = rand::rng().random_range(40000..43000); + let p2p = rand::rng().random_range(43000..47000); + (api, p2p) + } + }; + let api_addr = config.server.api_http_addr.split_once(":").unwrap().0; + config.server.api_http_addr = format!("{}:{}", api_addr, api); + config.server.p2p_config.port = p2p; + } + + /// Save default peers config for specified [`ChainTypes`]. + fn save_default_peers_config(chain_type: &ChainTypes) -> PeersConfig { + let sub_dir = Some(chain_type.shortname()); + let path = Settings::config_path(PeersConfig::FILE_NAME, sub_dir); + let config = PeersConfig::default(); + Settings::write_to_file(&config, path); + config + } + + /// Save node config to the file. + pub fn save(&self) { + let sub_dir = Some(self.node.server.chain_type.shortname()); + let config_path = Settings::config_path(SERVER_CONFIG_FILE_NAME, sub_dir); + Settings::write_to_file(&self.node, config_path); + } + + /// Get server config to use for node server before start. + pub fn node_server_config() -> ConfigMembers { + let r_config = Settings::node_config_to_read(); + r_config.node.clone() + } + + /// Reset node config to default values. + pub fn reset_to_default() { + let chain_type = { + let r_config = Settings::node_config_to_read(); + r_config.node.server.chain_type + }; + let node_server_config = Self::save_default_node_server_config(&chain_type); + let peers_config = Self::save_default_peers_config(&chain_type); + { + let mut w_config = Settings::node_config_to_update(); + w_config.node = node_server_config; + w_config.peers = peers_config; + } + } + + /// Check that the api secret files exist and are valid. + fn check_api_secret_files( + chain_type: &ChainTypes, + secret_file_name: &str, + ) -> Result<(), ConfigError> { + let api_secret_path = Self::get_secret_path(chain_type, secret_file_name); + if !api_secret_path.exists() { + config::init_api_secret(&api_secret_path) + } else { + config::check_api_secret(&api_secret_path) + } + } + + /// Get path for secret file. + fn get_secret_path(chain_type: &ChainTypes, secret_file_name: &str) -> PathBuf { + let sub_dir = Some(chain_type.shortname()); + let grin_path = Settings::base_path(sub_dir); + let mut api_secret_path = grin_path; + api_secret_path.push(secret_file_name); + api_secret_path + } + + /// List of available IP addresses. + pub fn get_ip_addrs() -> Vec { + let mut ip_addrs = Vec::new(); + let network_interfaces = list_afinet_netifas(); + if let Ok(network_interfaces) = network_interfaces { + for (_, ip) in network_interfaces.iter() { + if ip.is_ipv4() { + ip_addrs.push(ip.to_string()); + } + } + } + ip_addrs + } + + /// Check whether a port is available on the provided host. + fn is_host_port_available(host: &String, port: &String) -> bool { + if let Ok(p) = port.parse::() { + let ip_addr = Ipv4Addr::from_str(host.as_str()).unwrap(); + let ipv4 = SocketAddrV4::new(ip_addr, p); + return TcpListener::bind(ipv4).is_ok(); + } + false + } + + /// Check whether a port is available across the system at all hosts. + fn is_port_available(port: &String) -> bool { + if let Ok(p) = port.parse::() { + for ip in Self::get_ip_addrs() { + let ip_addr = Ipv4Addr::from_str(ip.as_str()).unwrap(); + let ipv4 = SocketAddrV4::new(ip_addr, p); + if TcpListener::bind(ipv4).is_err() { + return false; + } + } + } else { + return false; + } + true + } + + /// Get chain data path. + pub fn get_chain_data_path() -> String { + let r_config = Settings::node_config_to_read(); + r_config.node.server.db_root.clone() + } + + /// Save chain data path. + pub fn save_chain_data_path(path: String) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.db_root = path; + w_config.save(); + } + + /// Get stratum server IP address and port. + pub fn get_stratum_address() -> (String, String) { + let r_config = Settings::node_config_to_read(); + let saved_stratum_addr = r_config + .node + .server + .stratum_mining_config + .as_ref() + .unwrap() + .stratum_server_addr + .as_ref() + .unwrap(); + let (addr, port) = saved_stratum_addr.split_once(":").unwrap(); + (addr.into(), port.into()) + } + + /// Save stratum server IP address and port. + pub fn save_stratum_address(addr: &String, port: &String) { + let addr_to_save = format!("{}:{}", addr, port); + let mut w_config = Settings::node_config_to_update(); + w_config + .node + .server + .stratum_mining_config + .as_mut() + .unwrap() + .stratum_server_addr = Some(addr_to_save); + w_config.save(); + } + + /// Check if stratum server port is available across the system and config. + pub fn is_stratum_port_available(ip: &String, port: &String) -> bool { + if Node::get_stratum_stats().is_running { + // Check if Stratum server with same address is running. + let (cur_ip, cur_port) = Self::get_stratum_address(); + let same_running = ip == &cur_ip && port == &cur_port; + return same_running || Self::is_not_running_stratum_port_available(ip, port); + } + Self::is_not_running_stratum_port_available(&ip, &port) + } + + /// Check if stratum port is available when server is not running. + fn is_not_running_stratum_port_available(ip: &String, port: &String) -> bool { + if Self::is_host_port_available(&ip, &port) { + if &Self::get_p2p_port() != port { + let (api_ip, api_port) = Self::get_api_ip_port(); + return if &api_ip == ip { + &api_port != port + } else { + true + }; + } + } + false + } + + /// Get stratum mining server wallet address to get rewards. + pub fn get_stratum_wallet_id() -> Option { + let r_config = Settings::node_config_to_read(); + let id = r_config + .node + .clone() + .server + .stratum_mining_config + .unwrap() + .wallet_listener_url; + return if id.is_empty() { + None + } else { + if let Ok(id) = id.parse::() { + Some(id) + } else { + None + } + }; + } + + /// Save stratum mining server wallet address to get rewards. + pub fn save_stratum_wallet_id(id: i64) { + let mut w_config = Settings::node_config_to_update(); + w_config + .node + .server + .stratum_mining_config + .as_mut() + .unwrap() + .wallet_listener_url = id.to_string(); + w_config.save(); + } + + /// Get the amount of time in seconds to attempt to mine on a particular header. + pub fn get_stratum_attempt_time() -> String { + let r_config = Settings::node_config_to_read(); + r_config + .node + .server + .stratum_mining_config + .as_ref() + .unwrap() + .attempt_time_per_block + .to_string() + } + + /// Save stratum attempt time value in seconds. + pub fn save_stratum_attempt_time(time: u32) { + let mut w_config = Settings::node_config_to_update(); + w_config + .node + .server + .stratum_mining_config + .as_mut() + .unwrap() + .attempt_time_per_block = time; + w_config.save(); + } + + /// Get minimum acceptable share difficulty to request from miners. + pub fn get_stratum_min_share_diff() -> String { + let r_config = Settings::node_config_to_read(); + r_config + .node + .server + .stratum_mining_config + .as_ref() + .unwrap() + .minimum_share_difficulty + .to_string() + } + + /// Save minimum acceptable share difficulty. + pub fn save_stratum_min_share_diff(diff: u64) { + let mut w_config = Settings::node_config_to_update(); + w_config + .node + .server + .stratum_mining_config + .as_mut() + .unwrap() + .minimum_share_difficulty = diff; + w_config.save(); + } + + /// Check if stratum mining server autorun is enabled. + pub fn is_stratum_autorun_enabled() -> bool { + let r_config = Settings::node_config_to_read(); + let stratum_config = r_config.node.server.stratum_mining_config.as_ref().unwrap(); + if let Some(enable) = stratum_config.enable_stratum_server { + return enable; + } + false + } + + /// Toggle stratum mining server autorun. + pub fn toggle_stratum_autorun() { + let autorun = Self::is_stratum_autorun_enabled(); + let mut w_config = Settings::node_config_to_update(); + w_config + .node + .server + .stratum_mining_config + .as_mut() + .unwrap() + .enable_stratum_server = Some(!autorun); + w_config.save(); + } + + /// Get API server address. + pub fn get_api_address() -> String { + let r_config = Settings::node_config_to_read(); + r_config.node.server.api_http_addr.clone() + } + + /// Get API server IP and port. + pub fn get_api_ip_port() -> (String, String) { + let saved_addr = Self::get_api_address(); + let (addr, port) = saved_addr.split_once(":").unwrap(); + (addr.into(), port.into()) + } + + /// Save API server IP address and port. + pub fn save_api_address(addr: &String, port: &String) { + let addr_to_save = format!("{}:{}", addr, port); + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.api_http_addr = addr_to_save; + w_config.save(); + } + + /// Check if api server port is available across the system and config. + pub fn is_api_port_available(ip: &String, port: &String) -> bool { + if Node::is_running() { + // Check if API server with same address is running. + let same_running = NodeConfig::get_api_address() == format!("{}:{}", ip, port); + if same_running || Self::is_host_port_available(ip, port) { + return &Self::get_p2p_port() != port; + } + return false; + } else if Self::is_host_port_available(ip, port) { + return &Self::get_p2p_port() != port; + } + false + } + + /// Get API secret text. + pub fn get_api_secret(foreign: bool) -> Option { + let r_config = Settings::node_config_to_read(); + let api_secret_path = if foreign { + &r_config.node.server.foreign_api_secret_path + } else { + &r_config.node.server.api_secret_path + } + .clone(); + if let Some(secret_path) = api_secret_path { + if let Ok(file) = File::open(secret_path) { + let buf_reader = BufReader::new(file); + let mut lines_iter = buf_reader.lines(); + if let Some(Ok(line)) = lines_iter.next() { + return Some(line); + } + } + } + None + } + + /// Save API secret text. + pub fn save_api_secret(api_secret: &String) { + Self::save_secret(api_secret, API_SECRET_FILE_NAME); + } + + /// Update Foreign API secret. + pub fn save_foreign_api_secret(api_secret: &String) { + Self::save_secret(api_secret, FOREIGN_API_SECRET_FILE_NAME); + } + + /// Save secret value into specified file. + fn save_secret(value: &String, file_name: &str) { + // Remove config value to remove authorization. + if value.is_empty() { + let mut w_config = Settings::node_config_to_update(); + match file_name { + API_SECRET_FILE_NAME => w_config.node.server.api_secret_path = None, + _ => w_config.node.server.foreign_api_secret_path = None, + } + w_config.save(); + return; + } + + let mut secret_enabled = true; + // Get path for specified secret file. + let secret_path = { + let r_config = Settings::node_config_to_read(); + let path = match file_name { + API_SECRET_FILE_NAME => r_config.node.server.api_secret_path.clone(), + _ => r_config.node.server.foreign_api_secret_path.clone(), + }; + path.unwrap_or_else(|| { + secret_enabled = false; + let chain_type = AppConfig::chain_type(); + let path = Self::get_secret_path(&chain_type, file_name); + path.to_str().unwrap().to_string() + }) + }; + // Update secret path at config if authorization was disabled before. + if !secret_enabled { + let mut w_config = Settings::node_config_to_update(); + match file_name { + API_SECRET_FILE_NAME => { + w_config.node.server.api_secret_path = Some(secret_path.clone()) + } + _ => w_config.node.server.foreign_api_secret_path = Some(secret_path.clone()), + }; + + w_config.save(); + } + // Write secret text into file. + let mut secret_file = File::create(secret_path).unwrap(); + secret_file.write_all(value.as_bytes()).unwrap(); + } + + /// Get Future Time Limit. + pub fn get_ftl() -> String { + Settings::node_config_to_read() + .node + .server + .future_time_limit + .to_string() + } + + /// Save Future Time Limit. + pub fn save_ftl(ftl: u64) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.future_time_limit = ftl; + w_config.save(); + } + + /// Check if full chain validation mode is enabled. + pub fn is_full_chain_validation() -> bool { + let mode = Settings::node_config_to_read() + .node + .clone() + .server + .chain_validation_mode; + mode == ChainValidationMode::EveryBlock + } + + /// Toggle full chain validation. + pub fn toggle_full_chain_validation() { + let validation_enabled = Self::is_full_chain_validation(); + let new_mode = if validation_enabled { + ChainValidationMode::Disabled + } else { + ChainValidationMode::EveryBlock + }; + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.chain_validation_mode = new_mode; + w_config.save(); + } + + /// Check if node is running in archive mode. + pub fn is_archive_mode() -> bool { + let archive_mode = Settings::node_config_to_read() + .node + .clone() + .server + .archive_mode; + archive_mode.is_some() && archive_mode.unwrap() + } + + /// Toggle archive node mode. + pub fn toggle_archive_mode() { + let archive_mode = Self::is_archive_mode(); + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.archive_mode = Some(!archive_mode); + w_config.save(); + } + + /// Get P2P server port. + pub fn get_p2p_port() -> String { + Settings::node_config_to_read() + .node + .server + .p2p_config + .port + .to_string() + } + + /// Check if P2P server port is available across the system and config. + pub fn is_p2p_port_available(port: &String) -> bool { + if port.parse::().is_err() { + return false; + } + let (_, api_port) = Self::get_api_ip_port(); + if Node::is_running() { + // Check if P2P server with same port is running. + let same_running = &NodeConfig::get_p2p_port() == port; + if same_running || Self::is_port_available(port) { + return &api_port != port; + } + return false; + } else if Self::is_port_available(port) { + return &api_port != port; + } + false + } + + /// Save P2P server port. + pub fn save_p2p_port(port: u16) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.p2p_config.port = port; + w_config.save(); + } + + /// Check if default seed list is used. + pub fn is_default_seeding_type() -> bool { + Settings::node_config_to_read() + .node + .server + .p2p_config + .seeding_type + == Seeding::DNSSeed + } + + /// Toggle seeding type to use default or custom seed list. + pub fn toggle_seeding_type() { + let seeding_type = match Self::is_default_seeding_type() { + true => Seeding::List, + false => Seeding::DNSSeed, + }; + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.p2p_config.seeding_type = seeding_type; + w_config.save(); + } + + /// Get custom seed peers. + pub fn get_custom_seeds() -> Vec { + Settings::node_config_to_read().peers.seeds.clone() + } + + /// Save custom seed peer. + pub fn save_custom_seed(peer: String) { + let mut w_config = Settings::node_config_to_update(); + let size = w_config.peers.seeds.len(); + w_config.peers.seeds.insert(size, peer); + w_config.peers.save(); + } + + /// Remove custom seed peer. + pub fn remove_custom_seed(peer: &String) { + let mut w_config = Settings::node_config_to_update(); + let mut seeds = w_config.peers.seeds.clone(); + if let Some(index) = seeds.iter().position(|x| x == peer) { + seeds.remove(index); + } + w_config.peers.seeds = seeds; + w_config.peers.save(); + } + + /// Get denied peer list. + pub fn get_denied_peers() -> Vec { + Settings::node_config_to_read().peers.denied.clone() + } + + /// Save peer to denied list. + pub fn deny_peer(peer: String) { + let mut w_config = Settings::node_config_to_update(); + let size = w_config.peers.denied.len(); + w_config.peers.denied.insert(size, peer); + w_config.peers.save(); + } + + /// Remove denied peer. + pub fn remove_denied_peer(peer: &String) { + let mut w_config = Settings::node_config_to_update(); + let mut denied = w_config.peers.denied.clone(); + if let Some(index) = denied.iter().position(|x| x == peer) { + denied.remove(index); + } + w_config.peers.denied = denied; + w_config.peers.save(); + } + + /// Get allowed peer list. + pub fn get_allowed_peers() -> Vec { + Settings::node_config_to_read().peers.allowed.clone() + } + + /// Save peer to allowed list. + pub fn allow_peer(peer: String) { + let mut w_config = Settings::node_config_to_update(); + let size = w_config.peers.allowed.len(); + w_config.peers.allowed.insert(size, peer); + w_config.peers.save(); + } + + /// Remove allowed peer. + pub fn remove_allowed_peer(peer: &String) { + let mut w_config = Settings::node_config_to_update(); + let mut allowed = w_config.peers.allowed.clone(); + if let Some(index) = allowed.iter().position(|x| x == peer) { + allowed.remove(index); + } + w_config.peers.allowed = allowed; + w_config.peers.save(); + } + + /// Get preferred peer list. + pub fn get_preferred_peers() -> Vec { + Settings::node_config_to_read().peers.preferred.clone() + } + + /// Add peer at preferred list. + pub fn prefer_peer(peer: String) { + let mut w_config = Settings::node_config_to_update(); + let size = w_config.peers.preferred.len(); + w_config.peers.preferred.insert(size, peer); + w_config.peers.save(); + } + + /// Remove preferred peer. + pub fn remove_preferred_peer(peer: &String) { + let mut w_config = Settings::node_config_to_update(); + let mut preferred = w_config.peers.preferred.clone(); + if let Some(index) = preferred.iter().position(|x| x == peer) { + preferred.remove(index); + } + w_config.peers.preferred = preferred; + w_config.peers.save(); + } + + /// How long a banned peer should stay banned in ms. + pub fn get_p2p_ban_window() -> String { + Settings::node_config_to_read() + .node + .server + .p2p_config + .ban_window() + .to_string() + } + + /// Save for how long a banned peer should stay banned in ms. + pub fn save_p2p_ban_window(time: i64) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.p2p_config.ban_window = Some(time); + w_config.save(); + } + + /// Maximum number of inbound peer connections. + pub fn get_max_inbound_peers() -> String { + Settings::node_config_to_read() + .node + .server + .p2p_config + .peer_max_inbound_count() + .to_string() + } + + /// Save maximum number of inbound peer connections. + pub fn save_max_inbound_peers(count: u32) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.p2p_config.peer_max_inbound_count = Some(count); + w_config.save(); + } + + /// Maximum number of outbound peer connections. + pub fn get_max_outbound_peers() -> String { + Settings::node_config_to_read() + .node + .server + .p2p_config + .peer_max_outbound_count() + .to_string() + } + + /// Save maximum number of outbound peer connections. + pub fn save_max_outbound_peers(count: u32) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.p2p_config.peer_max_outbound_count = Some(count); + // Same value for preferred. + w_config + .node + .server + .p2p_config + .peer_min_preferred_outbound_count = Some(count); + w_config.save(); + } + + /// Base fee that's accepted into the pool. + pub fn get_base_fee() -> String { + Settings::node_config_to_read() + .node + .server + .pool_config + .accept_fee_base + .to_string() + } + + /// Save base fee that's accepted into the pool. + pub fn save_base_fee(fee: u64) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.pool_config.accept_fee_base = fee; + w_config.save(); + } + + /// Reorg cache retention period in minutes. + pub fn get_reorg_cache_period() -> String { + Settings::node_config_to_read() + .node + .server + .pool_config + .reorg_cache_period + .to_string() + } + + /// Save reorg cache retention period in minutes. + pub fn save_reorg_cache_period(period: u32) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.pool_config.reorg_cache_period = period; + w_config.save(); + } + + /// Max amount of transactions at pool. + pub fn get_max_pool_size() -> String { + Settings::node_config_to_read() + .node + .server + .pool_config + .max_pool_size + .to_string() + } + + /// Save max amount of transactions at pool. + pub fn save_max_pool_size(amount: usize) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.pool_config.max_pool_size = amount; + w_config.save(); + } + + /// Max amount of transactions at stem pool. + pub fn get_max_stempool_size() -> String { + Settings::node_config_to_read() + .node + .server + .pool_config + .max_stempool_size + .to_string() + } + + /// Save max amount of transactions at stem pool. + pub fn save_max_stempool_size(amount: usize) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.pool_config.max_stempool_size = amount; + w_config.save(); + } + + /// Max total weight of transactions that can get selected to build a block. + pub fn get_mineable_max_weight() -> String { + Settings::node_config_to_read() + .node + .server + .pool_config + .mineable_max_weight + .to_string() + } + + /// Set max total weight of transactions that can get selected to build a block. + pub fn save_mineable_max_weight(weight: u64) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.pool_config.mineable_max_weight = weight; + w_config.save(); + } + + // Dandelion settings + + /// Dandelion epoch duration in seconds. + pub fn get_dandelion_epoch() -> String { + Settings::node_config_to_read() + .node + .server + .dandelion_config + .epoch_secs + .to_string() + } + + /// Save Dandelion epoch duration in seconds. + pub fn save_dandelion_epoch(secs: u16) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.dandelion_config.epoch_secs = secs; + w_config.save(); + } + + /// Dandelion embargo timer in seconds. + /// Fluff and broadcast after embargo expires if tx not seen on network. + pub fn get_dandelion_embargo() -> String { + Settings::node_config_to_read() + .node + .server + .dandelion_config + .embargo_secs + .to_string() + } + + /// Save Dandelion embargo timer in seconds. + pub fn save_dandelion_embargo(secs: u16) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.dandelion_config.embargo_secs = secs; + w_config.save(); + } + + /// Dandelion aggregation period in seconds. + pub fn get_dandelion_aggregation() -> String { + Settings::node_config_to_read() + .node + .server + .dandelion_config + .aggregation_secs + .to_string() + } + + /// Save Dandelion aggregation period in seconds. + pub fn save_dandelion_aggregation(secs: u16) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.dandelion_config.aggregation_secs = secs; + w_config.save(); + } + + /// Dandelion stem probability (default: stem 90% of the time, fluff 10% of the time). + pub fn get_stem_probability() -> String { + Settings::node_config_to_read() + .node + .server + .dandelion_config + .stem_probability + .to_string() + } + + /// Save Dandelion stem probability. + pub fn save_stem_probability(percent: u8) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.dandelion_config.stem_probability = percent; + w_config.save(); + } + + /// Default to always stem our txs as described in Dandelion++ paper. + pub fn always_stem_our_txs() -> bool { + Settings::node_config_to_read() + .node + .server + .dandelion_config + .always_stem_our_txs + } + + /// Toggle stem of our txs. + pub fn toggle_always_stem_our_txs() { + let stem_txs = Self::always_stem_our_txs(); + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.dandelion_config.always_stem_our_txs = !stem_txs; + w_config.save(); + } + + /// Save database node storage directory path. + pub fn get_storage_path() -> String { + Settings::node_config_to_read().node.server.db_root.clone() + } + + /// Save database node storage directory path. + pub fn save_storage_path(path: String) { + let mut w_config = Settings::node_config_to_update(); + w_config.node.server.db_root = path; + w_config.save(); + } +} diff --git a/src/node/mine_block.rs b/src/node/mine_block.rs new file mode 100644 index 00000000..3b0bc6a5 --- /dev/null +++ b/src/node/mine_block.rs @@ -0,0 +1,304 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +//! Build a block to mine: gathers transactions from the pool, assembles +//! them into a block and returns it. + +use chrono::prelude::{DateTime, Utc}; +use rand::{Rng, rng}; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use crate::node::stratum::StratumStopState; +use grin_api; +use grin_chain; +use grin_core::core::{Output, TxKernel}; +use grin_core::libtx::ProofBuilder; +use grin_core::libtx::secp_ser; +use grin_core::{consensus, core, global}; +use grin_keychain::{ExtKeychain, Identifier, Keychain}; +use grin_servers::ServerTxPool; +use grin_servers::common::types::Error; +use log::{debug, error, trace, warn}; +use serde_derive::{Deserialize, Serialize}; + +/// Fees in block to use for coinbase amount calculation +/// (Duplicated from Grin wallet project) +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BlockFees { + /// fees + #[serde(with = "secp_ser::string_or_u64")] + pub fees: u64, + /// height + #[serde(with = "secp_ser::string_or_u64")] + pub height: u64, + /// key id + pub key_id: Option, +} + +impl BlockFees { + /// return key id + pub fn key_id(&self) -> Option { + self.key_id.clone() + } +} + +/// Response to build a coinbase output. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CbData { + /// Output + pub output: Output, + /// Kernel + pub kernel: TxKernel, + /// Key Id + pub key_id: Option, +} + +// Ensure a block suitable for mining is built and returned +// If a wallet listener URL is not provided the reward will be "burnt" +// Warning: This call does not return until/unless a new block can be built +pub fn get_block( + chain: &Arc, + tx_pool: &ServerTxPool, + key_id: Option, + wallet_listener_url: Option, + stop_state: &Arc, +) -> Option<(core::Block, BlockFees)> { + let wallet_retry_interval = 5; + // get the latest chain state and build a block on top of it + let mut result = build_block(chain, tx_pool, key_id.clone(), wallet_listener_url.clone()); + while let Err(e) = result { + let mut new_key_id = key_id.to_owned(); + match e { + Error::Chain(c) => match c { + grin_chain::Error::DuplicateCommitment(_) => { + debug!( + "Duplicate commit for potential coinbase detected. Trying next derivation." + ); + // use the next available key to generate a different coinbase commitment + new_key_id = None; + } + _ => { + error!("Chain Error: {:?}", c); + } + }, + Error::WalletComm(_) => { + error!( + "Error building new block: Can't connect to wallet listener at {:?}; will retry", + wallet_listener_url.as_ref().unwrap() + ); + thread::sleep(Duration::from_secs(wallet_retry_interval)); + } + ae => { + warn!("Error building new block: {:?}. Retrying.", ae); + } + } + + // only wait if we are still using the same key: a different coinbase commitment is unlikely + // to have duplication + if new_key_id.is_some() { + thread::sleep(Duration::from_millis(100)); + } + + // Stop attempts to build a block on stop. + if stop_state.is_stopped() { + return None; + } + result = build_block(chain, tx_pool, new_key_id, wallet_listener_url.clone()); + } + Some(result.unwrap()) +} + +/// Builds a new block with the chain head as previous and eligible +/// transactions from the pool. +fn build_block( + chain: &Arc, + tx_pool: &ServerTxPool, + key_id: Option, + wallet_listener_url: Option, +) -> Result<(core::Block, BlockFees), Error> { + let head = chain.head_header()?; + + // prepare the block header timestamp + let mut now_sec = Utc::now().timestamp(); + let head_sec = head.timestamp.timestamp(); + if now_sec <= head_sec { + now_sec = head_sec + 1; + } + + // Determine the difficulty our block should be at. + // Note: do not keep the difficulty_iter in scope (it has an active batch). + let difficulty = consensus::next_difficulty(head.height + 1, chain.difficulty_iter()?); + + // Extract current "mineable" transactions from the pool. + // If this fails for *any* reason then fallback to an empty vec of txs. + // This will allow us to mine an "empty" block if the txpool is in an + // invalid (and unexpected) state. + let txs = match tx_pool.read().prepare_mineable_transactions() { + Ok(txs) => txs, + Err(e) => { + error!( + "build_block: Failed to prepare mineable txs from txpool: {:?}", + e + ); + warn!("build_block: Falling back to mining empty block."); + vec![] + } + }; + + // build the coinbase and the block itself + let fees = txs.iter().map(|tx| tx.fee()).sum(); + let height = head.height + 1; + let block_fees = BlockFees { + fees, + key_id, + height, + }; + + let (output, kernel, block_fees) = get_coinbase(wallet_listener_url, block_fees)?; + let mut b = core::Block::from_reward(&head, &txs, output, kernel, difficulty.difficulty)?; + + // making sure we're not spending time mining a useless block + b.validate(&head.total_kernel_offset)?; + + b.header.pow.nonce = rng().random(); + b.header.pow.secondary_scaling = difficulty.secondary_scaling; + b.header.timestamp = DateTime::from_timestamp(now_sec, 0).unwrap(); + + debug!( + "Built new block with {} inputs and {} outputs, block difficulty: {}, cumulative difficulty {}", + b.inputs().len(), + b.outputs().len(), + difficulty.difficulty, + b.header.total_difficulty().to_num(), + ); + + // Now set txhashset roots and sizes on the header of the block being built. + match chain.set_txhashset_roots(&mut b) { + Ok(_) => Ok((b, block_fees)), + Err(e) => { + match e { + // If this is a duplicate commitment then likely trying to use + // a key that hass already been derived but not in the wallet + // for some reason, allow caller to retry. + grin_chain::Error::DuplicateCommitment(e) => { + Err(Error::Chain(grin_chain::Error::DuplicateCommitment(e))) + } + + // Some other issue, possibly duplicate kernel + _ => { + error!("Error setting txhashset root to build a block: {:?}", e); + Err(Error::Chain(grin_chain::Error::Other(format!("{:?}", e)))) + } + } + } + } +} + +/// +/// Probably only want to do this when testing. +/// +fn burn_reward(block_fees: BlockFees) -> Result<(Output, TxKernel, BlockFees), Error> { + warn!("Burning block fees: {:?}", block_fees); + let keychain = ExtKeychain::from_random_seed(global::is_testnet())?; + let key_id = ExtKeychain::derive_key_id(1, 1, 0, 0, 0); + let (out, kernel) = grin_core::libtx::reward::output( + &keychain, + &ProofBuilder::new(&keychain), + &key_id, + block_fees.fees, + false, + ) + .unwrap(); + Ok((out, kernel, block_fees)) +} + +// Connect to the wallet listener and get coinbase. +// Warning: If a wallet listener URL is not provided the reward will be "burnt" +fn get_coinbase( + wallet_listener_url: Option, + block_fees: BlockFees, +) -> Result<(Output, TxKernel, BlockFees), Error> { + return match wallet_listener_url { + None => { + // Burn it + burn_reward(block_fees) + } + Some(wallet_listener_url) => { + let res = create_coinbase(&wallet_listener_url, &block_fees)?; + let output = res.output; + let kernel = res.kernel; + let key_id = res.key_id; + let block_fees = BlockFees { + key_id, + ..block_fees + }; + + debug!("get_coinbase: {:?}", block_fees); + Ok((output, kernel, block_fees)) + } + }; +} + +/// Call the wallet API to create a coinbase output for the given block_fees. +/// Will retry based on default "retry forever with backoff" behavior. +fn create_coinbase(dest: &str, block_fees: &BlockFees) -> Result { + let url = format!("{}/v2/foreign", dest); + let req_body = json!({ + "jsonrpc": "2.0", + "method": "build_coinbase", + "id": 1, + "params": { + "block_fees": block_fees + } + }); + + trace!("Sending build_coinbase request: {}", req_body); + let req = grin_api::client::create_post_request(url.as_str(), None, &req_body)?; + let timeout = grin_api::client::TimeOut::default(); + let res: String = grin_api::client::send_request(req, timeout).map_err(|e| { + let report = format!( + "Failed to get coinbase from {}. Is the wallet listening? {:?}", + dest, e + ); + error!("{}", report); + Error::WalletComm(report) + })?; + + let res: Value = serde_json::from_str(&res).unwrap(); + trace!("Response: {}", res); + if res["error"] != json!(null) { + let report = format!( + "Failed to get coinbase from {}: Error: {}, Message: {}", + dest, res["error"]["code"], res["error"]["message"] + ); + error!("{}", report); + return Err(Error::WalletComm(report)); + } + + let cb_data = res["result"]["Ok"].clone(); + trace!("cb_data: {}", cb_data); + let ret_val = match serde_json::from_value::(cb_data) { + Ok(r) => r, + Err(e) => { + let report = format!("Couldn't deserialize CbData: {}", e); + error!("{}", report); + return Err(Error::WalletComm(report)); + } + }; + + Ok(ret_val) +} diff --git a/src/node/mod.rs b/src/node/mod.rs new file mode 100644 index 00000000..73090511 --- /dev/null +++ b/src/node/mod.rs @@ -0,0 +1,25 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +mod mine_block; +mod stratum; + +mod node; +pub use node::Node; + +mod config; +pub use config::*; + +mod types; +pub use types::*; diff --git a/src/node/node.rs b/src/node/node.rs new file mode 100644 index 00000000..14fa1e24 --- /dev/null +++ b/src/node/node.rs @@ -0,0 +1,881 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use futures::channel::oneshot; +use lazy_static::lazy_static; +use parking_lot::RwLock; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use std::{fs, thread}; + +use crate::node::stratum::{StratumServer, StratumStopState}; +use crate::node::{NodeConfig, NodeError, PeersConfig}; +use grin_chain::SyncStatus; +use grin_core::global; +use grin_core::global::ChainTypes; +use grin_p2p::Seeding; +use grin_p2p::msg::PeerAddrs; +use grin_servers::common::types::Error; +use grin_servers::{Server, ServerStats, StratumServerConfig, StratumStats}; +use log::error; + +lazy_static! { + /// Static thread-aware state of [`Node`] to be updated from separate thread. + static ref NODE_STATE: Arc = Arc::new(Node::default()); +} + +/// Provides [`Server`] control, holds current status and statistics. +pub struct Node { + /// Node [`Server`] statistics information. + stats: Arc>>, + + /// [`StratumServer`] statistics information. + stratum_stats: Arc>, + /// Flag to start [`StratumServer`]. + start_stratum_needed: AtomicBool, + /// State to stop [`StratumServer`] from outside. + stratum_stop_state: Arc, + + /// Indicator if node [`Server`] is starting. + starting: AtomicBool, + /// Flag to stop the [`Server`] and start it again. + restart_needed: AtomicBool, + /// Flag to stop the [`Server`]. + stop_needed: AtomicBool, + /// Flag to check if app exit is needed after [`Server`] stop. + exit_after_stop: AtomicBool, + /// Flag to reset data and restart the [`Server`]. + reset_data: AtomicBool, + /// Flag to change data directory and restart the [`Server`]. + change_data_dir: AtomicBool, + + /// An error occurred on [`Server`] start. + error: Arc>>, +} + +impl Default for Node { + fn default() -> Self { + Self { + stats: Arc::new(RwLock::new(None)), + stratum_stats: Arc::new(grin_util::RwLock::new(StratumStats::default())), + stratum_stop_state: Arc::new(StratumStopState::default()), + starting: AtomicBool::new(false), + restart_needed: AtomicBool::new(false), + stop_needed: AtomicBool::new(false), + exit_after_stop: AtomicBool::new(false), + start_stratum_needed: AtomicBool::new(false), + error: Arc::new(RwLock::new(None)), + reset_data: AtomicBool::new(false), + change_data_dir: AtomicBool::new(false), + } + } +} + +impl Node { + /// Delay for thread to update the stats. + pub const STATS_UPDATE_DELAY: Duration = Duration::from_millis(1000); + + /// Default Mainnet DNS Seeds + pub const MAINNET_DNS_SEEDS: &[&str] = &[ + "mainnet-seed.grinnode.live", // info@grinnode.live + "grincoin.org", // xmpp:aglkm@conversations.im + "main.gri.mw", // admin@gri.mw + "mainnet.grinffindor.org", // support@grinffindor.org + "main-seed.grin.money", // support@grinily.com + ]; + + /// Default Testnet DNS Seeds + pub const TESTNET_DNS_SEEDS: &[&str] = &[ + "testnet.grincoin.org", // xmpp:aglkm@conversations.im + "test.gri.mw", // admin@gri.mw + "testnet.grinffindor.org", // support@grinffindor.org + "test-seed.grin.money", // support@grinily.com + ]; + + /// Stop the [`Server`] and setup exit flag after if needed. + pub fn stop(exit_after_stop: bool) { + NODE_STATE.stop_needed.store(true, Ordering::Relaxed); + NODE_STATE + .exit_after_stop + .store(exit_after_stop, Ordering::Relaxed); + } + + /// Request to start the [`Node`]. + pub fn start() { + if !Self::is_running() { + Self::start_server_thread(); + } + } + + /// Request to restart the [`Node`]. + pub fn restart() { + if Self::is_running() { + NODE_STATE.restart_needed.store(true, Ordering::Relaxed); + } else { + Node::start(); + } + } + + /// Request to start [`StratumServer`]. + pub fn start_stratum() { + NODE_STATE + .start_stratum_needed + .store(true, Ordering::Relaxed); + } + + /// Check if [`StratumServer`] is starting. + pub fn is_stratum_starting() -> bool { + NODE_STATE.start_stratum_needed.load(Ordering::Relaxed) + } + + /// Get [`StratumServer`] statistics. + pub fn get_stratum_stats() -> StratumStats { + NODE_STATE.stratum_stats.read().clone() + } + + /// Stop [`StratumServer`]. + pub fn stop_stratum() { + NODE_STATE.stratum_stop_state.stop() + } + + /// Check if [`StratumServer`] is stopping. + pub fn is_stratum_stopping() -> bool { + NODE_STATE.stratum_stop_state.is_stopped() + } + + /// Check if [`Node`] is starting. + pub fn is_starting() -> bool { + NODE_STATE.starting.load(Ordering::Relaxed) + } + + /// Check if [`Node`] is running. + pub fn is_running() -> bool { + Self::get_sync_status().is_some() + } + + /// Check if [`Node`] is stopping. + pub fn is_stopping() -> bool { + NODE_STATE.stop_needed.load(Ordering::Relaxed) + } + + /// Check if [`Node`] is restarting. + pub fn is_restarting() -> bool { + NODE_STATE.restart_needed.load(Ordering::Relaxed) || Self::reset_data_needed() + } + + /// Check if reset of [`Server`] peers is needed. + fn reset_data_needed() -> bool { + NODE_STATE.reset_data.load(Ordering::Relaxed) + } + + /// Get node [`Server`] statistics. + pub fn get_stats() -> Option { + NODE_STATE.stats.read().clone() + } + + /// Check if [`Server`] is not syncing (disabled or just running after synchronization). + pub fn not_syncing() -> bool { + match Node::get_sync_status() { + None => true, + Some(ss) => ss == SyncStatus::NoSync, + } + } + + /// Get synchronization status, empty when [`Server`] is not running. + pub fn get_sync_status() -> Option { + // Return Shutdown status when node is stopping. + if Self::is_stopping() { + return Some(SyncStatus::Shutdown); + } + + // Return Initial status when node is starting or restarting or peers are deleting. + if Self::is_starting() || Self::is_restarting() { + return Some(SyncStatus::Initial); + } + + let stats = Self::get_stats(); + // Return sync status when server is running (stats are not empty). + if stats.is_some() { + return Some(stats.as_ref().unwrap().sync_status); + } + None + } + + /// Get [`Server`] error. + pub fn get_error() -> Option { + let r_err = NODE_STATE.error.read(); + if r_err.is_some() { + let e = r_err.as_ref().unwrap(); + // Flag setup to show an error to clean up data. + let store_err = match e { + Error::Store(_) => true, + Error::Chain(_) => true, + _ => false, + }; + if store_err { + return Some(NodeError::Storage); + } + + // Flag setup to show P2P or API server error. + let p2p_api_err = match e { + Error::P2P(_) => Some(NodeError::P2P), + Error::API(_) => Some(NodeError::API), + _ => None, + }; + if p2p_api_err.is_some() { + return p2p_api_err; + } + + // Flag setup to show configuration error. + let config_err = match e { + Error::Configuration(_) => true, + _ => false, + }; + return if config_err { + Some(NodeError::Configuration) + } else { + Some(NodeError::Unknown) + }; + } + None + } + + /// Start the [`Server`] at separate thread to update state with stats and handle statuses. + fn start_server_thread() { + thread::spawn(move || { + NODE_STATE.starting.store(true, Ordering::Relaxed); + // Start the server. + match start_node_server() { + Ok(mut server) => { + let mut first_start = true; + loop { + // Restart server if request or peers clean up is needed + if Self::is_restarting() { + server.stop(); + // Wait server after stop. + thread::sleep(Duration::from_millis(5000)); + // Reset data if requested. + if Self::reset_data_needed() { + Node::reset_data(true); + } + // Reset stratum stats. + { + let mut w_stratum_stats = NODE_STATE.stratum_stats.write(); + *w_stratum_stats = StratumStats::default(); + } + // Create new server. + match start_node_server() { + Ok(s) => { + server = s; + NODE_STATE.restart_needed.store(false, Ordering::Relaxed); + } + Err(e) => { + error!("Error starting node: {:?}", e); + { + let mut w_err = NODE_STATE.error.write(); + *w_err = Some(e); + } + // Reset server state. + Self::reset_server_state(true); + break; + } + } + } else if Self::is_stopping() { + // Stop the server. + server.stop(); + // Clean stats and statuses. + Self::reset_server_state(false); + break; + } + + // Start stratum mining server if requested. + let stratum_start_requested = Self::is_stratum_starting(); + if stratum_start_requested { + let (s_ip, s_port) = NodeConfig::get_stratum_address(); + if NodeConfig::is_stratum_port_available(&s_ip, &s_port) { + let stratum_config = + server.config.stratum_mining_config.clone().unwrap(); + start_stratum_mining_server(&server, stratum_config); + } + } + + // Update server stats. + if let Ok(stats) = server.get_server_stats() { + { + let mut w_stats = NODE_STATE.stats.write(); + *w_stats = Some(stats.clone()); + } + + if first_start { + NODE_STATE.starting.store(false, Ordering::Relaxed); + first_start = false; + } + } + + // Reset stratum server start flag. + if stratum_start_requested && NODE_STATE.stratum_stats.read().is_running { + NODE_STATE + .start_stratum_needed + .store(false, Ordering::Relaxed); + } + + thread::sleep(Self::STATS_UPDATE_DELAY); + } + } + Err(e) => { + error!("Error starting node: {:?}", e); + { + let mut w_err = NODE_STATE.error.write(); + *w_err = Some(e); + } + // Reset server state. + Self::reset_server_state(true); + } + } + }); + } + + /// Clean up [`Server`] stats and statuses. + fn reset_server_state(has_error: bool) { + NODE_STATE.starting.store(false, Ordering::Relaxed); + NODE_STATE.restart_needed.store(false, Ordering::Relaxed); + NODE_STATE + .start_stratum_needed + .store(false, Ordering::Relaxed); + NODE_STATE.stop_needed.store(false, Ordering::Relaxed); + + // Reset stratum stats. + { + let mut w_stratum_stats = NODE_STATE.stratum_stats.write(); + *w_stratum_stats = StratumStats::default(); + } + // Reset server stats. + { + let mut w_stats = NODE_STATE.stats.write(); + *w_stats = None; + } + // Reset an error if needed. + if !has_error { + let mut w_err = NODE_STATE.error.write(); + *w_err = None; + } + } + + /// Change chain data directory. + pub fn change_data_dir(path: String) { + if Self::data_dir_changing() || NodeConfig::get_chain_data_path() == path { + return; + } + NODE_STATE.change_data_dir.store(true, Ordering::Relaxed); + thread::spawn(move || { + let running = Node::is_running(); + if running { + Node::stop(false); + // Wait node to stop before moving files. + while Node::is_running() { + thread::sleep(Self::STATS_UPDATE_DELAY); + } + } + let cfg_path = NodeConfig::get_chain_data_path(); + let old = Path::new(cfg_path.as_str()); + let new = Path::new(path.as_str()); + if !old.exists() { + NodeConfig::save_chain_data_path(path); + } else { + fs::create_dir_all(&new).unwrap_or_default(); + if let Ok(_) = fs::rename(old, new) { + NodeConfig::save_chain_data_path(path); + } else { + fs::remove_dir_all(old).unwrap(); + NodeConfig::save_chain_data_path(path); + } + } + NODE_STATE.change_data_dir.store(false, Ordering::Relaxed); + // Restart node after migration. + if running && !Node::is_stopping() { + Node::start(); + } + }); + } + + /// Check if chain data directory is changing. + pub fn data_dir_changing() -> bool { + NODE_STATE.change_data_dir.load(Ordering::Relaxed) + } + + /// Clean-up [`Server`] data if server is not running. + pub fn clean_up_data() { + if Self::is_running() { + return; + } + let config = NodeConfig::node_server_config(); + let server_config = config.server.clone(); + let dirs_to_remove: Vec<&str> = vec!["header", "lmdb", "txhashset"]; + for dir in dirs_to_remove { + let mut path = PathBuf::from(&server_config.db_root); + path.push(dir); + if path.exists() { + fs::remove_dir_all(path).unwrap(); + } + } + } + + /// Reset [`Server`] data. + pub fn reset_data(force: bool) { + if force || !Node::is_running() { + let config = NodeConfig::node_server_config(); + let server_config = config.server.clone(); + // Remove data folder. + let data_dir = PathBuf::from(&server_config.db_root); + match fs::remove_dir_all(data_dir) { + Ok(_) => {} + Err(_) => {} + } + NODE_STATE.reset_data.store(false, Ordering::Relaxed); + } else { + NODE_STATE.reset_data.store(true, Ordering::Relaxed); + } + } + + /// Get synchronization status i18n text. + pub fn get_sync_status_text() -> String { + if Node::data_dir_changing() { + return t!("moving_files").into(); + } + + if Node::is_stopping() { + return t!("sync_status.shutdown").into(); + }; + if Node::is_starting() { + return t!("sync_status.initial").into(); + }; + if Node::is_restarting() { + return t!("sync_status.node_restarting").into(); + } + + let sync_status = Self::get_sync_status(); + + if sync_status.is_none() { + return t!("sync_status.node_down").into(); + } + + let sync_status = match sync_status.unwrap() { + SyncStatus::Initial => t!("sync_status.initial"), + SyncStatus::NoSync => t!("sync_status.no_sync"), + SyncStatus::AwaitingPeers(_) => t!("sync_status.awaiting_peers"), + SyncStatus::HeaderSync { + sync_head, + highest_height, + .. + } => { + if highest_height == 0 { + t!("sync_status.header_sync") + } else { + let percent = sync_head.height * 100 / highest_height; + t!("sync_status.header_sync_percent", "percent" => percent) + } + } + SyncStatus::TxHashsetPibd { + aborted: _, + errored: _, + completed_leaves, + leaves_required, + completed_to_height: _, + required_height: _, + } => { + if completed_leaves == 0 { + t!("sync_status.tx_hashset_pibd") + } else { + let percent = completed_leaves * 100 / leaves_required; + t!("sync_status.tx_hashset_pibd_percent", "percent" => percent) + } + } + SyncStatus::TxHashsetDownload(stat) => { + if stat.total_size > 0 { + let percent = stat.downloaded_size * 100 / stat.total_size; + t!("sync_status.tx_hashset_download_percent", "percent" => percent) + } else { + t!("sync_status.tx_hashset_download") + } + } + SyncStatus::TxHashsetSetup { + headers, + headers_total, + kernel_pos, + kernel_pos_total, + } => { + if headers.is_some() && headers_total.is_some() { + let h = headers.unwrap(); + let ht = headers_total.unwrap(); + let percent = h * 100 / ht; + t!("sync_status.tx_hashset_setup_history", "percent" => percent) + } else if kernel_pos.is_some() && kernel_pos_total.is_some() { + let k = kernel_pos.unwrap(); + let kt = kernel_pos_total.unwrap(); + let percent = k * 100 / kt; + t!("sync_status.tx_hashset_setup_position", "percent" => percent) + } else { + t!("sync_status.tx_hashset_setup") + } + } + SyncStatus::TxHashsetRangeProofsValidation { + rproofs, + rproofs_total, + } => { + let r_percent = if rproofs_total > 0 { + (rproofs * 100) / rproofs_total + } else { + 0 + }; + t!("sync_status.tx_hashset_range_proofs_validation", "percent" => r_percent) + } + SyncStatus::TxHashsetKernelsValidation { + kernels, + kernels_total, + } => { + let k_percent = if kernels_total > 0 { + (kernels * 100) / kernels_total + } else { + 0 + }; + t!("sync_status.tx_hashset_kernels_validation", "percent" => k_percent) + } + SyncStatus::TxHashsetSave | SyncStatus::TxHashsetDone => { + t!("sync_status.tx_hashset_save") + } + SyncStatus::BodySync { + current_height, + highest_height, + } => { + if highest_height == 0 { + t!("sync_status.body_sync") + } else { + let percent = current_height * 100 / highest_height; + t!("sync_status.body_sync_percent", "percent" => percent) + } + } + SyncStatus::Shutdown => t!("sync_status.shutdown"), + }; + sync_status.into() + } +} + +/// Start the node [`Server`]. +fn start_node_server() -> Result { + // Setup server config. + let mut config = NodeConfig::node_server_config(); + PeersConfig::load_to_server_config(&mut config); + let mut server_config = config.server.clone(); + + // DNS seed setup. + if NodeConfig::is_default_seeding_type() { + server_config.p2p_config.seeding_type = Seeding::List; + server_config.p2p_config.seeds = Some(PeerAddrs::default()); + let is_mainnet = server_config.chain_type == ChainTypes::Mainnet; + let seed_list = if is_mainnet { + Node::MAINNET_DNS_SEEDS + } else { + Node::TESTNET_DNS_SEEDS + }; + let seed_port = if is_mainnet { 3414 } else { 13414 }; + for seed_addr in seed_list { + let addr = format!("{}:{}", seed_addr, seed_port); + if let Some(p) = PeersConfig::peer_to_addr(addr) { + server_config + .p2p_config + .seeds + .as_mut() + .unwrap() + .peers + .push(p) + } + } + } + + // Fix to avoid too many opened files. + server_config.p2p_config.peer_min_preferred_outbound_count = + server_config.p2p_config.peer_max_outbound_count; + + // Remove temporary file dir. + { + let mut tmp_dir = PathBuf::from(&server_config.db_root); + tmp_dir = tmp_dir.parent().unwrap().to_path_buf(); + tmp_dir.push("tmp"); + if tmp_dir.exists() { + match fs::remove_dir_all(tmp_dir) { + Ok(_) => {} + Err(_) => {} + } + } + } + + // Initialize our global chain_type, feature flags (NRD kernel support currently), + // accept_fee_base, and future_time_limit. + // These are read via global and not read from config beyond this point. + if !global::GLOBAL_CHAIN_TYPE.is_init() { + global::init_global_chain_type(config.server.chain_type); + } else { + global::set_global_chain_type(config.server.chain_type); + global::set_local_chain_type(config.server.chain_type); + } + + if !global::GLOBAL_NRD_FEATURE_ENABLED.is_init() { + match global::get_chain_type() { + ChainTypes::Mainnet => { + global::init_global_nrd_enabled(false); + } + _ => { + global::init_global_nrd_enabled(true); + } + } + } else { + match global::get_chain_type() { + ChainTypes::Mainnet => { + global::set_global_nrd_enabled(false); + } + _ => { + global::set_global_nrd_enabled(true); + } + } + } + + let afb = config.server.pool_config.accept_fee_base; + if !global::GLOBAL_ACCEPT_FEE_BASE.is_init() { + global::init_global_accept_fee_base(afb); + } else { + global::set_global_accept_fee_base(afb); + } + + let future_time_limit = config.server.future_time_limit; + if !global::GLOBAL_FUTURE_TIME_LIMIT.is_init() { + global::init_global_future_time_limit(future_time_limit); + } else { + global::set_global_future_time_limit(future_time_limit); + } + + // Put flag to start stratum server if autorun is available. + if NodeConfig::is_stratum_autorun_enabled() { + NODE_STATE + .start_stratum_needed + .store(true, Ordering::Relaxed); + } + + // Reset an error. + { + let mut w_err = NODE_STATE.error.write(); + *w_err = None; + } + + // Start integrated node server. + let api_chan: &'static mut (oneshot::Sender<()>, oneshot::Receiver<()>) = + Box::leak(Box::new(oneshot::channel::<()>())); + let server_result = Server::new(server_config, None, None, api_chan); + server_result +} + +/// Start stratum mining server on a separate thread. +pub fn start_stratum_mining_server(server: &Server, config: StratumServerConfig) { + let proof_size = global::proofsize(); + let sync_state = server.sync_state.clone(); + + let mut stratum_server = StratumServer::new( + config, + server.chain.clone(), + server.tx_pool.clone(), + NODE_STATE.stratum_stats.clone(), + ); + let stop_state = NODE_STATE.stratum_stop_state.clone(); + stop_state.reset(); + let server_state = stop_state.clone(); + thread::spawn(move || { + stratum_server.run_loop(proof_size, sync_state, stop_state); + server_state.reset(); + // Reset stratum stats. + { + let mut w_stratum_stats = NODE_STATE.stratum_stats.write(); + *w_stratum_stats = StratumStats::default(); + } + }); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Get sync status text for Android notification from [`NODE_STATE`] in Java string format. +pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncStatusText( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) -> jni::sys::jstring { + let status_text = Node::get_sync_status_text(); + let j_text = _env.new_string(status_text); + return j_text.unwrap().into_raw(); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Get sync title for Android notification in Java string format. +pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncTitle( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) -> jni::sys::jstring { + let j_text = _env.new_string(t!("network.node")); + return j_text.unwrap().into_raw(); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Get start text for Android notification in Java string format. +pub extern "C" fn Java_mw_gri_android_BackgroundService_getStartText( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) -> jni::sys::jstring { + let j_text = _env.new_string(t!("network_settings.enable")); + return j_text.unwrap().into_raw(); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Get stop text for Android notification in Java string format. +pub extern "C" fn Java_mw_gri_android_BackgroundService_getStopText( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) -> jni::sys::jstring { + let j_text = _env.new_string(t!("network_settings.disable")); + return j_text.unwrap().into_raw(); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Get exit text for Android notification in Java string format. +pub extern "C" fn Java_mw_gri_android_BackgroundService_getExitText( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) -> jni::sys::jstring { + let j_text = _env.new_string(t!("modal_exit.exit")); + return j_text.unwrap().into_raw(); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Check if node launch is possible. +pub extern "C" fn Java_mw_gri_android_BackgroundService_canStartNode( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) -> jni::sys::jboolean { + let loading = Node::is_stopping() || Node::is_restarting() || Node::is_starting(); + return (!loading && !Node::is_running()) as jni::sys::jboolean; +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Check if node stop is possible. +pub extern "C" fn Java_mw_gri_android_BackgroundService_canStopNode( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) -> jni::sys::jboolean { + let loading = Node::is_stopping() || Node::is_restarting() || Node::is_starting(); + return (!loading && Node::is_running()) as jni::sys::jboolean; +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Start node from Android Java code. +pub extern "C" fn Java_mw_gri_android_NotificationActionsReceiver_startNode( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) { + Node::start(); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Stop node from Android Java code. +pub extern "C" fn Java_mw_gri_android_NotificationActionsReceiver_stopNode( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) { + Node::stop(false); +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Stop node from Android Java code. +pub extern "C" fn Java_mw_gri_android_NotificationActionsReceiver_stopNodeToExit( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) { + if Node::is_running() { + Node::stop(true); + } else { + NODE_STATE.exit_after_stop.store(true, Ordering::Relaxed); + } +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Check if app exit is needed after node stop to finish Android app at background. +pub extern "C" fn Java_mw_gri_android_BackgroundService_exitAppAfterNodeStop( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) -> jni::sys::jboolean { + let exit_needed = !Node::is_running() && NODE_STATE.exit_after_stop.load(Ordering::Relaxed); + return exit_needed as jni::sys::jboolean; +} + +#[allow(dead_code)] +#[cfg(target_os = "android")] +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +/// Handle unexpected application termination on Android (removal from recent apps). +pub extern "C" fn Java_mw_gri_android_MainActivity_onTermination( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + _activity: jni::objects::JObject, +) { + Node::stop(false); +} diff --git a/src/node/stratum.rs b/src/node/stratum.rs new file mode 100644 index 00000000..d0c9acd0 --- /dev/null +++ b/src/node/stratum.rs @@ -0,0 +1,1000 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +//! Mining Stratum Server + +use futures::channel::mpsc; +use futures::pin_mut; +use futures::{SinkExt, StreamExt, TryStreamExt}; +use tokio_old::net::TcpListener; +use tokio_old::runtime::Runtime; +use tokio_util_old::codec::{Framed, LinesCodec}; + +use chrono::prelude::Utc; +use futures::future::{AbortHandle, abortable}; +use grin_util::RwLock; +use serde_json::Value; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, SystemTime}; + +use grin_chain::{self, SyncState}; +use grin_core::consensus::graph_weight; +use grin_core::core::Block; +use grin_core::core::hash::Hashed; +use grin_core::global::min_edge_bits; +use grin_core::{pow, ser}; +use grin_servers::ServerTxPool; +use grin_servers::common::stats::{StratumStats, WorkerStats}; +use grin_servers::common::types::StratumServerConfig; +use grin_util::ToHex; + +use crate::node::mine_block::get_block; +use crate::wallet::WalletConfig; +use log::{debug, error}; +use serde_derive::{Deserialize, Serialize}; + +type Tx = mpsc::UnboundedSender; + +// ---------------------------------------- +// http://www.jsonrpc.org/specification +// RPC Methods + +/// Represents a compliant JSON RPC 2.0 id. +/// Valid id: Integer, String. +#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[serde(untagged)] +enum JsonId { + IntId(u32), + StrId(String), +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct RpcRequest { + id: JsonId, + jsonrpc: String, + method: String, + params: Option, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct RpcResponse { + id: JsonId, + jsonrpc: String, + method: String, + result: Option, + error: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +struct RpcError { + code: i32, + message: String, +} + +impl RpcError { + pub fn internal_error() -> Self { + RpcError { + code: 32603, + message: "Internal error".to_owned(), + } + } + pub fn node_is_syncing() -> Self { + RpcError { + code: -32000, + message: "Node is syncing - Please wait".to_owned(), + } + } + pub fn method_not_found() -> Self { + RpcError { + code: -32601, + message: "Method not found".to_owned(), + } + } + pub fn too_late() -> Self { + RpcError { + code: -32503, + message: "Solution submitted too late".to_string(), + } + } + pub fn cannot_validate() -> Self { + RpcError { + code: -32502, + message: "Failed to validate solution".to_string(), + } + } + pub fn too_low_difficulty() -> Self { + RpcError { + code: -32501, + message: "Share rejected due to low difficulty".to_string(), + } + } + pub fn invalid_request() -> Self { + RpcError { + code: -32600, + message: "Invalid Request".to_string(), + } + } +} + +impl From for Value { + fn from(e: RpcError) -> Self { + serde_json::to_value(e).unwrap() + } +} + +impl From for RpcError +where + T: std::error::Error, +{ + fn from(e: T) -> Self { + println!("Received unhandled error: {}", e); + RpcError::internal_error() + } +} + +#[derive(Serialize, Deserialize, Debug)] +struct LoginParams { + login: String, + pass: String, + agent: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct SubmitParams { + height: u64, + job_id: u64, + nonce: u64, + edge_bits: u32, + pow: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct JobTemplate { + height: u64, + job_id: u64, + difficulty: u64, + pre_pow: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct WorkerStatus { + id: String, + height: u64, + difficulty: u64, + accepted: u64, + rejected: u64, + stale: u64, +} + +struct State { + current_block_versions: Vec, + // to prevent the wallet from generating a new HD key derivation for each + // iteration, we keep the returned derivation to provide it back when + // nothing has changed. We only want to create a key_id for each new block, + // and reuse it when we rebuild the current block to add new tx. + current_key_id: Option, + current_difficulty: u64, // scaled + minimum_share_difficulty: u64, // unscaled +} + +impl State { + pub fn new(minimum_share_difficulty: u64) -> Self { + let blocks = vec![Block::default()]; + State { + current_block_versions: blocks, + current_key_id: None, + current_difficulty: ::max_value(), + minimum_share_difficulty: minimum_share_difficulty, + } + } +} + +/// Stratum server stop state to stop it from node thread. +pub struct StratumStopState { + stopping: AtomicBool, +} + +impl Default for StratumStopState { + fn default() -> Self { + Self { + stopping: AtomicBool::new(false), + } + } +} + +impl StratumStopState { + /// Check if stratum server should be stopped. + pub fn is_stopped(&self) -> bool { + self.stopping.load(Ordering::Relaxed) + } + + /// Called to stop stratum server from node thread. + pub fn stop(&self) { + self.stopping.store(true, Ordering::Relaxed); + } + + /// Called before stratum server start to reset state. + pub fn reset(&self) { + self.stopping.store(false, Ordering::Relaxed); + } +} + +struct Handler { + id: String, + workers: Arc, + sync_state: Arc, + chain: Arc, + current_state: Arc>, +} + +impl Handler { + pub fn new( + id: String, + stratum_stats: Arc>, + sync_state: Arc, + minimum_share_difficulty: u64, + chain: Arc, + ) -> Self { + Handler { + id: id, + workers: Arc::new(WorkersList::new(stratum_stats)), + sync_state: sync_state, + chain: chain, + current_state: Arc::new(RwLock::new(State::new(minimum_share_difficulty))), + } + } + pub fn from_stratum(stratum: &StratumServer) -> Self { + Handler::new( + stratum.id.clone(), + stratum.stratum_stats.clone(), + stratum.sync_state.clone(), + stratum.config.minimum_share_difficulty, + stratum.chain.clone(), + ) + } + fn handle_rpc_requests(&self, request: RpcRequest, worker_id: usize) -> String { + self.workers.last_seen(worker_id); + + // Call the handler function for requested method + let response = match request.method.as_str() { + "login" => self.handle_login(request.params, worker_id), + "submit" => { + let res = self.handle_submit(request.params, worker_id); + // this key_id has been used now, reset + if let Ok((_, true)) = res { + self.current_state.write().current_key_id = None; + } + res.map(|(v, _)| v) + } + "keepalive" => self.handle_keepalive(), + "getjobtemplate" => { + if self.sync_state.is_syncing() { + Err(RpcError::node_is_syncing()) + } else { + self.handle_getjobtemplate() + } + } + "status" => self.handle_status(worker_id), + _ => { + // Called undefined method + Err(RpcError::method_not_found()) + } + }; + + // Package the reply as RpcResponse json + let resp = match response { + Err(rpc_error) => RpcResponse { + id: request.id, + jsonrpc: String::from("2.0"), + method: request.method, + result: None, + error: Some(rpc_error.into()), + }, + Ok(response) => RpcResponse { + id: request.id, + jsonrpc: String::from("2.0"), + method: request.method, + result: Some(response), + error: None, + }, + }; + serde_json::to_string(&resp).unwrap() + } + fn handle_login(&self, params: Option, worker_id: usize) -> Result { + let params: LoginParams = parse_params(params)?; + self.workers.login(worker_id, params.login, params.agent)?; + return Ok("ok".into()); + } + + // Handle KEEPALIVE message + fn handle_keepalive(&self) -> Result { + return Ok("ok".into()); + } + + fn handle_status(&self, worker_id: usize) -> Result { + // Return worker status in json for use by a dashboard or healthcheck. + let stats = self.workers.get_stats(worker_id)?; + let status = WorkerStatus { + id: stats.id.clone(), + height: self + .current_state + .read() + .current_block_versions + .last() + .unwrap() + .header + .height, + difficulty: stats.pow_difficulty, + accepted: stats.num_accepted, + rejected: stats.num_rejected, + stale: stats.num_stale, + }; + let response = serde_json::to_value(&status).unwrap(); + return Ok(response); + } + // Handle GETJOBTEMPLATE message + fn handle_getjobtemplate(&self) -> Result { + // Build a JobTemplate from a BlockHeader and return JSON + let job_template = self.build_block_template(); + let response = serde_json::to_value(&job_template).unwrap(); + println!( + "(Server ID: {}) sending block {} with id {} to single worker", + self.id, job_template.height, job_template.job_id, + ); + return Ok(response); + } + + // Build and return a JobTemplate for mining the current block + fn build_block_template(&self) -> JobTemplate { + let bh = self + .current_state + .read() + .current_block_versions + .last() + .unwrap() + .header + .clone(); + // Serialize the block header into pre and post nonce strings + let mut header_buf = vec![]; + { + let mut writer = ser::BinWriter::default(&mut header_buf); + bh.write_pre_pow(&mut writer).unwrap(); + bh.pow.write_pre_pow(&mut writer).unwrap(); + } + let pre_pow = header_buf.to_hex(); + let current_state = self.current_state.read(); + let job_template = JobTemplate { + height: bh.height, + job_id: (current_state.current_block_versions.len() - 1) as u64, + difficulty: current_state.minimum_share_difficulty, + pre_pow, + }; + return job_template; + } + // Handle SUBMIT message + // params contains a solved block header + // We accept and log valid shares of all difficulty above configured minimum + // Accepted shares that are full solutions will also be submitted to the + // network + fn handle_submit( + &self, + params: Option, + worker_id: usize, + ) -> Result<(Value, bool), RpcError> { + // Validate parameters + let params: SubmitParams = parse_params(params)?; + + let state = self.current_state.read(); + // Find the correct version of the block to match this header + let b: Option<&Block> = state.current_block_versions.get(params.job_id as usize); + if params.height != state.current_block_versions.last().unwrap().header.height + || b.is_none() + { + // Return error status + println!( + "(Server ID: {}) Share at height {}, edge_bits {}, nonce {}, job_id {} submitted too late", + self.id, params.height, params.edge_bits, params.nonce, params.job_id, + ); + self.workers.update_stats(worker_id, |ws| ws.num_stale += 1); + return Err(RpcError::too_late()); + } + + let scaled_share_difficulty: u64; + let unscaled_share_difficulty: u64; + let mut share_is_block = false; + + let mut b: Block = b.unwrap().clone(); + // Reconstruct the blocks header with this nonce and pow added + b.header.pow.proof.edge_bits = params.edge_bits as u8; + b.header.pow.nonce = params.nonce; + b.header.pow.proof.nonces = params.pow; + + if !b.header.pow.is_primary() && !b.header.pow.is_secondary() { + // Return error status + println!( + "(Server ID: {}) Failed to validate solution at height {}, hash {}, edge_bits {}, nonce {}, job_id {}: cuckoo size too small", + self.id, + params.height, + b.hash(), + params.edge_bits, + params.nonce, + params.job_id, + ); + self.workers + .update_stats(worker_id, |worker_stats| worker_stats.num_rejected += 1); + return Err(RpcError::cannot_validate()); + } + + // Get share difficulty values + scaled_share_difficulty = b.header.pow.to_difficulty(b.header.height).to_num(); + unscaled_share_difficulty = b.header.pow.to_unscaled_difficulty().to_num(); + // Note: state.minimum_share_difficulty is unscaled + // state.current_difficulty is scaled + // If the difficulty is too low its an error + if unscaled_share_difficulty < state.minimum_share_difficulty { + // Return error status + println!( + "(Server ID: {}) Share at height {}, hash {}, edge_bits {}, nonce {}, job_id {} rejected due to low difficulty: {}/{}", + self.id, + params.height, + b.hash(), + params.edge_bits, + params.nonce, + params.job_id, + unscaled_share_difficulty, + state.minimum_share_difficulty, + ); + self.workers + .update_stats(worker_id, |worker_stats| worker_stats.num_rejected += 1); + return Err(RpcError::too_low_difficulty()); + } + + // If the difficulty is high enough, submit it (which also validates it) + if scaled_share_difficulty >= state.current_difficulty { + // This is a full solution, submit it to the network + let res = self + .chain + .process_block(b.clone(), grin_chain::Options::MINE); + if let Err(e) = res { + // Return error status + println!( + "(Server ID: {}) Failed to validate solution at height {}, hash {}, edge_bits {}, nonce {}, job_id {}, {:?}", + self.id, + params.height, + b.hash(), + params.edge_bits, + params.nonce, + params.job_id, + e, + ); + self.workers + .update_stats(worker_id, |worker_stats| worker_stats.num_rejected += 1); + return Err(RpcError::cannot_validate()); + } + share_is_block = true; + self.workers + .update_stats(worker_id, |worker_stats| worker_stats.num_blocks_found += 1); + self.workers.stratum_stats.write().blocks_found += 1; + // Log message to make it obvious we found a block + let stats = self.workers.get_stats(worker_id)?; + println!( + "(Server ID: {}) Solution Found for block {}, hash {} - Yay!!! Worker ID: {}, blocks found: {}, shares: {}", + self.id, + params.height, + b.hash(), + stats.id, + stats.num_blocks_found, + stats.num_accepted, + ); + } else { + // Do some validation but dont submit + let res = pow::verify_size(&b.header); + if res.is_err() { + // Return error status + println!( + "(Server ID: {}) Failed to validate share at height {}, hash {}, edge_bits {}, nonce {}, job_id {}. {:?}", + self.id, + params.height, + b.hash(), + params.edge_bits, + b.header.pow.nonce, + params.job_id, + res, + ); + self.workers + .update_stats(worker_id, |worker_stats| worker_stats.num_rejected += 1); + return Err(RpcError::cannot_validate()); + } + } + // Log this as a valid share + self.workers.update_edge_bits(params.edge_bits as u16); + let worker = self.workers.get_worker(worker_id)?; + let submitted_by = match worker.login { + None => worker.id.to_string(), + Some(login) => login, + }; + + println!( + "(Server ID: {}) Got share at height {}, hash {}, edge_bits {}, nonce {}, job_id {}, difficulty {}/{}, submitted by {}", + self.id, + b.header.height, + b.hash(), + b.header.pow.proof.edge_bits, + b.header.pow.nonce, + params.job_id, + scaled_share_difficulty, + state.current_difficulty, + submitted_by, + ); + self.workers + .update_stats(worker_id, |worker_stats| worker_stats.num_accepted += 1); + let submit_response = if share_is_block { + format!("blockfound - {}", b.hash().to_hex()) + } else { + "ok".to_string() + }; + return Ok(( + serde_json::to_value(submit_response).unwrap(), + share_is_block, + )); + } // handle submit a solution + + fn broadcast_job(&self) { + debug!("broadcast job"); + // Package new block into RpcRequest + let job_template = self.build_block_template(); + let job_template_json = serde_json::to_string(&job_template).unwrap(); + // Issue #1159 - use a serde_json Value type to avoid extra quoting + let job_template_value: Value = serde_json::from_str(&job_template_json).unwrap(); + let job_request = RpcRequest { + id: JsonId::StrId(String::from("Stratum")), + jsonrpc: String::from("2.0"), + method: String::from("job"), + params: Some(job_template_value), + }; + let job_request_json = serde_json::to_string(&job_request).unwrap(); + debug!( + "(Server ID: {}) sending block {} with id {} to stratum clients", + self.id, job_template.height, job_template.job_id, + ); + self.workers.broadcast(job_request_json); + } + + pub fn run( + &self, + config: &StratumServerConfig, + tx_pool: &ServerTxPool, + stop_state: Arc, + ) { + debug!("Run main loop"); + let mut deadline: i64 = 0; + let mut head = self.chain.head().unwrap(); + let mut current_hash = head.prev_block_h; + loop { + if stop_state.is_stopped() { + thread::sleep(Duration::from_millis(1500)); + break; + } + // get the latest chain state + head = self.chain.head().unwrap(); + let latest_hash = head.last_block_h; + + // Build a new block if there is at least one worker and + // There is a new block on the chain or its time to rebuild + // the current one to include new transactions + if (current_hash != latest_hash || Utc::now().timestamp() >= deadline) + && self.workers.count() > 0 + { + { + debug!("resend updated block"); + let mut state = self.current_state.write(); + let wallet_listener_url = if !config.burn_reward { + if let Ok(id) = config.wallet_listener_url.parse::() { + if let Some(port) = WalletConfig::read_api_port_by_id(id) { + let url = format!("http://127.0.0.1:{}", port); + Some(url) + } else { + None + } + } else { + None + } + } else { + None + }; + // If this is a new block we will clear the current_block version history + let clear_blocks = current_hash != latest_hash; + + // Build the new block (version) + if let Some((new_block, block_fees)) = get_block( + &self.chain, + tx_pool, + state.current_key_id.clone(), + wallet_listener_url, + &stop_state, + ) { + // scaled difficulty + state.current_difficulty = + (new_block.header.total_difficulty() - head.total_difficulty).to_num(); + + state.current_key_id = block_fees.key_id(); + + current_hash = latest_hash; + // set the minimum acceptable share unscaled difficulty for this block + state.minimum_share_difficulty = config.minimum_share_difficulty; + + // set a new deadline for rebuilding with fresh transactions + deadline = Utc::now().timestamp() + config.attempt_time_per_block as i64; + + // If this is a new block we will clear the current_block version history + if clear_blocks { + state.current_block_versions.clear(); + } + + // Update the mining stats + self.workers.update_block_height(new_block.header.height); + let difficulty = + new_block.header.total_difficulty() - head.total_difficulty; + self.workers.update_network_difficulty(difficulty.to_num()); + self.workers.update_network_hashrate(); + + // Add this new block candidate onto our list of block versions for height + state.current_block_versions.push(new_block); + } else { + thread::sleep(Duration::from_millis(1500)); + break; + } + } + // Send this job to all connected workers + self.broadcast_job(); + } + + // sleep before restarting loop + thread::sleep(Duration::from_millis(5)); + } // Main Loop + } +} + +// ---------------------------------------- +// Worker Factory Thread Function +fn accept_connections( + listen_addr: SocketAddr, + handler: Arc, + stop_state: Arc, +) { + debug!("Start tokio stratum server"); + let task = async move { + let mut listener = TcpListener::bind(&listen_addr).await.unwrap_or_else(|_| { + panic!("Stratum: Failed to bind to listen address {}", listen_addr) + }); + let server = listener + .incoming() + .filter_map(|s| async { s.map_err(|e| error!("accept error = {:?}", e)).ok() }) + .for_each(move |socket| { + let handler = handler.clone(); + async move { + // Spawn a task to process the connection + let (tx, mut rx) = mpsc::unbounded(); + + let worker_id = handler.workers.add_worker(tx); + debug!("Worker {} connected", worker_id); + + let framed = Framed::new(socket, LinesCodec::new()); + let (mut writer, mut reader) = framed.split(); + + let h = handler.clone(); + let read = async move { + while let Some(line) = reader + .try_next() + .await + .map_err(|e| debug!("error reading line: {}", e))? + { + let request = serde_json::from_str(&line) + .map_err(|e| debug!("error serializing line: {}", e))?; + let resp = h.handle_rpc_requests(request, worker_id); + h.workers.send_to(worker_id, resp); + } + + Result::<_, ()>::Ok(()) + }; + + let write = async move { + while let Some(line) = rx.next().await { + writer + .send(line) + .await + .map_err(|e| debug!("error writing line: {}", e))?; + } + + Result::<_, ()>::Ok(()) + }; + + let task = async move { + pin_mut!(read, write); + futures::future::select(read, write).await; + handler.workers.remove_worker(worker_id); + debug!("Worker {} disconnected", worker_id); + }; + tokio_old::spawn(task); + } + }); + server.await + }; + + let mut rt = Runtime::new().unwrap(); + let (task, handle) = abortable(task); + rt.spawn(check_stop_state(stop_state, handle)); + rt.block_on(task).unwrap_or_default(); +} + +async fn check_stop_state(stop_state: Arc, handle: AbortHandle) { + loop { + // Ping stratum socket on stop to handle TcpListener unbind. + if stop_state.is_stopped() { + handle.abort(); + break; + } + thread::sleep(Duration::from_millis(1000)); + } +} + +// ---------------------------------------- +// Worker Object - a connected stratum client - a miner, pool, proxy, etc... + +#[derive(Clone)] +pub struct Worker { + id: usize, + agent: String, + login: Option, + authenticated: bool, + tx: Tx, +} + +impl Worker { + /// Creates a new Stratum Worker. + pub fn new(id: usize, tx: Tx) -> Worker { + Worker { + id: id, + agent: String::from(""), + login: None, + authenticated: false, + tx: tx, + } + } +} // impl Worker + +struct WorkersList { + workers_list: Arc>>, + stratum_stats: Arc>, +} + +impl WorkersList { + pub fn new(stratum_stats: Arc>) -> Self { + WorkersList { + workers_list: Arc::new(RwLock::new(HashMap::new())), + stratum_stats: stratum_stats, + } + } + + pub fn add_worker(&self, tx: Tx) -> usize { + let mut stratum_stats = self.stratum_stats.write(); + let worker_id = stratum_stats.worker_stats.len(); + let worker = Worker::new(worker_id, tx); + let mut workers_list = self.workers_list.write(); + workers_list.insert(worker_id, worker); + + let mut worker_stats = WorkerStats::default(); + worker_stats.is_connected = true; + worker_stats.id = worker_id.to_string(); + worker_stats.pow_difficulty = stratum_stats.minimum_share_difficulty; + stratum_stats.worker_stats.push(worker_stats); + stratum_stats.num_workers = workers_list.len(); + worker_id + } + pub fn remove_worker(&self, worker_id: usize) { + self.update_stats(worker_id, |ws| ws.is_connected = false); + let mut stratum_stats = self.stratum_stats.write(); + let mut workers_list = self.workers_list.write(); + workers_list + .remove(&worker_id) + .expect("Stratum: no such addr in map"); + + stratum_stats.num_workers = workers_list.len(); + } + + pub fn login(&self, worker_id: usize, login: String, agent: String) -> Result<(), RpcError> { + let mut wl = self.workers_list.write(); + let worker = wl + .get_mut(&worker_id) + .ok_or_else(RpcError::internal_error)?; + worker.login = Some(login); + // XXX TODO Future - Validate password? + worker.agent = agent; + worker.authenticated = true; + Ok(()) + } + + pub fn get_worker(&self, worker_id: usize) -> Result { + self.workers_list + .read() + .get(&worker_id) + .ok_or_else(|| { + error!("Worker {} not found", worker_id); + RpcError::internal_error() + }) + .map(|w| w.clone()) + } + + pub fn get_stats(&self, worker_id: usize) -> Result { + self.stratum_stats + .read() + .worker_stats + .get(worker_id) + .ok_or_else(RpcError::internal_error) + .map(|ws| ws.clone()) + } + + pub fn last_seen(&self, worker_id: usize) { + //self.stratum_stats.write().worker_stats[worker_id].last_seen = SystemTime::now(); + self.update_stats(worker_id, |ws| ws.last_seen = SystemTime::now()); + } + + pub fn update_stats(&self, worker_id: usize, f: impl FnOnce(&mut WorkerStats) -> ()) { + let mut stratum_stats = self.stratum_stats.write(); + f(&mut stratum_stats.worker_stats[worker_id]); + } + + pub fn send_to(&self, worker_id: usize, msg: String) { + let _ = self + .workers_list + .read() + .get(&worker_id) + .unwrap() + .tx + .unbounded_send(msg); + } + + pub fn broadcast(&self, msg: String) { + for worker in self.workers_list.read().values() { + let _ = worker.tx.unbounded_send(msg.clone()); + } + } + + pub fn count(&self) -> usize { + self.workers_list.read().len() + } + + pub fn update_edge_bits(&self, edge_bits: u16) { + { + let mut stratum_stats = self.stratum_stats.write(); + stratum_stats.edge_bits = edge_bits; + } + self.update_network_hashrate(); + } + + pub fn update_block_height(&self, height: u64) { + let mut stratum_stats = self.stratum_stats.write(); + stratum_stats.block_height = height; + } + + pub fn update_network_difficulty(&self, difficulty: u64) { + let mut stratum_stats = self.stratum_stats.write(); + stratum_stats.network_difficulty = difficulty; + } + + pub fn update_network_hashrate(&self) { + let mut stratum_stats = self.stratum_stats.write(); + stratum_stats.network_hashrate = 42.0 + * (stratum_stats.network_difficulty as f64 + / graph_weight(stratum_stats.block_height, stratum_stats.edge_bits as u8) as f64) + / 60.0; + } +} + +// ---------------------------------------- +// Grin Stratum Server + +pub struct StratumServer { + id: String, + config: StratumServerConfig, + chain: Arc, + pub tx_pool: ServerTxPool, + sync_state: Arc, + stratum_stats: Arc>, +} + +impl StratumServer { + /// Creates a new Stratum Server. + pub fn new( + config: StratumServerConfig, + chain: Arc, + tx_pool: ServerTxPool, + stratum_stats: Arc>, + ) -> StratumServer { + StratumServer { + id: String::from("0"), + config, + chain, + tx_pool, + sync_state: Arc::new(SyncState::new()), + stratum_stats: stratum_stats, + } + } + + /// "main()" - Starts the stratum-server. Creates a thread to Listens for + /// a connection, then enters a loop, building a new block on top of the + /// existing chain anytime required and sending that to the connected + /// stratum miner, proxy, or pool, and accepts full solutions to + /// be submitted. + pub fn run_loop( + &mut self, + proof_size: usize, + sync_state: Arc, + stop_state: Arc, + ) { + debug!( + "(Server ID: {}) Starting stratum server with proof_size = {}", + self.id, proof_size + ); + + self.sync_state = sync_state; + + let listen_addr = self + .config + .stratum_server_addr + .clone() + .unwrap() + .parse() + .expect("Stratum: Incorrect address "); + + let handler = Arc::new(Handler::from_stratum(&self)); + let h = handler.clone(); + + let stop_socket = stop_state.clone(); + let check_state = stop_socket.clone(); + let _listener_th = thread::spawn(move || { + accept_connections(listen_addr, h, stop_socket); + }); + + // We have started + { + let mut stratum_stats = self.stratum_stats.write(); + stratum_stats.is_running = true; + stratum_stats.edge_bits = (min_edge_bits() + 1) as u16; + stratum_stats.minimum_share_difficulty = self.config.minimum_share_difficulty; + } + + // Initial Loop. Waiting node complete syncing + while self.sync_state.is_syncing() { + thread::sleep(Duration::from_millis(50)); + } + + handler.run(&self.config, &self.tx_pool, check_state); + } // fn run_loop() +} // StratumServer + +// Utility function to parse a JSON RPC parameter object, returning a proper +// error if things go wrong. +fn parse_params(params: Option) -> Result +where + for<'de> T: serde::Deserialize<'de>, +{ + params + .and_then(|v| serde_json::from_value(v).ok()) + .ok_or_else(RpcError::invalid_request) +} diff --git a/src/node/types.rs b/src/node/types.rs new file mode 100644 index 00000000..3db9c134 --- /dev/null +++ b/src/node/types.rs @@ -0,0 +1,28 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +/// Integrated node error type. +#[derive(Clone)] +pub enum NodeError { + /// Storage issue. + Storage, + /// P2P server issue. + P2P, + /// API server issue. + API, + /// Configuration issue. + Configuration, + /// Unknown error. + Unknown, +} diff --git a/src/nostr/avatar.rs b/src/nostr/avatar.rs new file mode 100644 index 00000000..be98fb96 --- /dev/null +++ b/src/nostr/avatar.rs @@ -0,0 +1,252 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Client-side avatar handling: local preprocessing of a picked picture +//! (mirrors the server pipeline so uploads over the mixnet stay small and previews +//! are instant — the server still re-validates everything), plus a small +//! disk cache of fetched avatars keyed by username. + +use image::codecs::png::PngEncoder; +use image::metadata::Orientation; +use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Cursor; +use std::path::PathBuf; + +/// Output dimensions (square), matching the server. +pub const SIZE: u32 = 256; +/// Raw picked files larger than this are rejected before decoding. +const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; + +/// Identify the image format from magic bytes alone (PNG/JPEG/WebP). +fn sniff(raw: &[u8]) -> Option { + if raw.len() >= 8 && raw.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + return Some(ImageFormat::Png); + } + if raw.len() >= 3 && raw.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some(ImageFormat::Jpeg); + } + if raw.len() >= 12 && &raw[0..4] == b"RIFF" && &raw[8..12] == b"WEBP" { + return Some(ImageFormat::WebP); + } + None +} + +/// Read a picked picture file and normalize it to the canonical 256×256 +/// PNG (EXIF orientation applied, every byte of metadata destroyed). +pub fn process_avatar_file(path: &str) -> Result, String> { + let meta = std::fs::metadata(path).map_err(|_| "Couldn't read that file".to_string())?; + if meta.len() > MAX_FILE_BYTES { + return Err("That picture is too large (10 MB max)".to_string()); + } + let raw = std::fs::read(path).map_err(|_| "Couldn't read that file".to_string())?; + process_avatar_bytes(&raw) +} + +/// Normalize raw image bytes to the canonical avatar PNG. +pub fn process_avatar_bytes(raw: &[u8]) -> Result, String> { + let err = || "That file doesn't look like a usable picture".to_string(); + let format = sniff(raw).ok_or_else(err)?; + let mut reader = ImageReader::with_format(Cursor::new(raw), format); + let mut limits = Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + let mut decoder = reader.into_decoder().map_err(|_| err())?; + let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); + let mut img = DynamicImage::from_decoder(decoder).map_err(|_| err())?; + img.apply_orientation(orientation); + let (w, h) = (img.width(), img.height()); + if w == 0 || h == 0 { + return Err(err()); + } + let side = w.min(h); + let img = img.crop_imm((w - side) / 2, (h - side) / 2, side, side); + let img = img.resize_exact(SIZE, SIZE, image::imageops::FilterType::Lanczos3); + let rgba = img.to_rgba8(); + let mut out = Vec::new(); + rgba.write_with_encoder(PngEncoder::new(&mut out)) + .map_err(|_| err())?; + Ok(out) +} + +/// One cached profile probe. +#[derive(Serialize, Deserialize, Clone)] +pub struct CacheEntry { + /// Avatar content hash; `None` records a confirmed has-no-avatar. + pub hash: Option, + /// When the server was last asked, unix seconds. + pub checked_at: i64, +} + +/// Disk cache of fetched avatars: `

/.png` files plus an index +/// mapping names to hashes with probe timestamps (negative entries too). +pub struct AvatarCache { + dir: PathBuf, + index: HashMap, +} + +const PRESENT_TTL_SECS: i64 = 24 * 3600; +const ABSENT_TTL_SECS: i64 = 6 * 3600; + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +impl AvatarCache { + /// Open (or create) the cache at the given directory. + pub fn new(dir: PathBuf) -> Self { + let _ = std::fs::create_dir_all(&dir); + let index = std::fs::read(dir.join("index.json")) + .ok() + .and_then(|raw| serde_json::from_slice(&raw).ok()) + .unwrap_or_default(); + Self { dir, index } + } + + fn save_index(&self) { + if let Ok(raw) = serde_json::to_vec(&self.index) { + let _ = std::fs::write(self.dir.join("index.json"), raw); + } + } + + /// Cached avatar bytes for a name, if a fresh positive entry exists. + pub fn cached(&self, name: &str) -> Option<(String, Vec)> { + let entry = self.index.get(name)?; + let hash = entry.hash.clone()?; + let bytes = std::fs::read(self.dir.join(format!("{hash}.png"))).ok()?; + Some((hash, bytes)) + } + + /// Whether the entry for a name is missing or past its TTL. + pub fn stale(&self, name: &str) -> bool { + match self.index.get(name) { + None => true, + Some(e) => { + let ttl = if e.hash.is_some() { + PRESENT_TTL_SECS + } else { + ABSENT_TTL_SECS + }; + unix_now() - e.checked_at > ttl + } + } + } + + /// Record a fetched avatar. + pub fn store(&mut self, name: &str, hash: &str, png: &[u8]) { + let _ = std::fs::write(self.dir.join(format!("{hash}.png")), png); + self.index.insert( + name.to_string(), + CacheEntry { + hash: Some(hash.to_string()), + checked_at: unix_now(), + }, + ); + self.save_index(); + } + + /// Record a confirmed has-no-avatar probe. + pub fn mark_absent(&mut self, name: &str) { + self.index.insert( + name.to_string(), + CacheEntry { + hash: None, + checked_at: unix_now(), + }, + ); + self.save_index(); + } + + /// Forget a name (released, rotated away, or replaced). + pub fn remove(&mut self, name: &str) { + if let Some(CacheEntry { + hash: Some(hash), .. + }) = self.index.remove(name) + { + // Unlink only when no other name shares the file. + let shared = self + .index + .values() + .any(|e| e.hash.as_deref() == Some(hash.as_str())); + if !shared { + let _ = std::fs::remove_file(self.dir.join(format!("{hash}.png"))); + } + } + self.save_index(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::RgbaImage; + + fn png_bytes(w: u32, h: u32) -> Vec { + let img = RgbaImage::from_fn(w, h, |x, y| { + image::Rgba([(x % 256) as u8, (y % 256) as u8, 7, 255]) + }); + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_with_encoder(PngEncoder::new(&mut out)) + .unwrap(); + out + } + + #[test] + fn processes_to_canonical_png() { + let out = process_avatar_bytes(&png_bytes(500, 300)).unwrap(); + assert!(out.starts_with(&[0x89, b'P', b'N', b'G'])); + let img = image::load_from_memory(&out).unwrap(); + assert_eq!((img.width(), img.height()), (SIZE, SIZE)); + } + + #[test] + fn rejects_non_images() { + assert!(process_avatar_bytes(b"").is_err()); + assert!(process_avatar_bytes(b"GIF89a....").is_err()); + assert!(process_avatar_bytes(&[]).is_err()); + } + + #[test] + fn cache_round_trip_and_remove() { + let dir = std::env::temp_dir().join(format!("goblin-avatar-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut cache = AvatarCache::new(dir.clone()); + assert!(cache.stale("ada")); + cache.store("ada", "ab12", b"pngbytes"); + assert!(!cache.stale("ada")); + let (hash, bytes) = cache.cached("ada").unwrap(); + assert_eq!(hash, "ab12"); + assert_eq!(bytes, b"pngbytes"); + // Reload from disk. + let cache2 = AvatarCache::new(dir.clone()); + assert!(cache2.cached("ada").is_some()); + // Negative entries. + let mut cache = cache2; + cache.mark_absent("bob"); + assert!(!cache.stale("bob")); + assert!(cache.cached("bob").is_none()); + // Removal unlinks unshared files. + cache.remove("ada"); + assert!(cache.cached("ada").is_none()); + assert!(!dir.join("ab12.png").exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/nostr/client.rs b/src/nostr/client.rs new file mode 100644 index 00000000..b6897557 --- /dev/null +++ b/src/nostr/client.rs @@ -0,0 +1,1549 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Per-wallet nostr service: relay connections over the Nym mixnet, +//! identity event publishing, the guarded ingest loop and the DM send path. + +use log::{error, info, warn}; +use nostr_sdk::{ + Client, Event, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification, + RelayStatus, Tag, TagKind, Timestamp, ToBech32, +}; +use parking_lot::{Mutex, RwLock}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + +use crate::nostr::ingest::{IngestContext, IngestDecision, decide}; +use crate::nostr::protocol; +use crate::nostr::relays::MAX_DM_RELAYS; +use crate::nostr::types::*; +use crate::nostr::{NostrConfig, NostrIdentity, NostrStore}; +use crate::nym::NymWebSocketTransport; +use crate::wallet::Wallet; +use crate::wallet::types::WalletTask; + +/// A peer's published nostr profile (kind-0 metadata), used to confirm a +/// pasted key belongs to a live identity before paying it. +pub struct NostrProfile { + pub name: Option, + pub nip05: Option, +} + +/// Subscription look-back window beyond the last connection time: gift wrap +/// timestamps are randomized up to 2 days into the past (NIP-59), use 3 days. +const LOOKBACK_SECS: i64 = 3 * 86_400; +/// Catch-up fetch timeout. +const FETCH_TIMEOUT: Duration = Duration::from_secs(30); +/// Send dispatch timeout. +const SEND_TIMEOUT: Duration = Duration::from_secs(40); +/// Rate limit for incoming messages per known contact (events/hour). +const RATE_CONTACT_PER_HOUR: usize = 30; +/// Rate limit for incoming messages per unknown sender (events/hour). +const RATE_UNKNOWN_PER_HOUR: usize = 10; +/// Auto-resend window for pending outgoing messages (days). +const RESEND_WINDOW_SECS: i64 = 7 * 86_400; +/// How often a cached @username is re-validated against the identity server, so +/// a released or reassigned name stops being shown. Doubles as the freshness +/// gate in `resolve_contact_identity`. +const NAME_REVERIFY_INTERVAL_SECS: i64 = 78; +/// Cap on contacts re-verified per sweep, so a large contact list rolls through +/// instead of bursting dozens of simultaneous mixnet lookups at once. +const NAME_REVERIFY_MAX_PER_TICK: usize = 8; + +/// Per-wallet nostr service. +pub struct NostrService { + /// Identity keys (decrypted for the session). + keys: Keys, + /// Identity file state. + pub identity: RwLock, + /// Per-wallet configuration. + pub config: RwLock, + /// Metadata archive. + pub store: Arc, + /// Directory holding identity.json. + nostr_dir: PathBuf, + + /// SDK client, present while the service loop runs. + client: RwLock>, + /// Handle to the service's tokio runtime. One-shot fetches (e.g. profile + /// lookups) from worker threads MUST run here, not on a throwaway runtime: + /// the relay connections (incl. the custom Nym mixnet transport) are driven + /// by this runtime, and a foreign runtime can't reach them. + rt_handle: RwLock>, + /// Service thread started flag. + started: AtomicBool, + /// Shutdown request flag. + shutdown: AtomicBool, + /// At least one relay is connected. + connected: AtomicBool, + /// New payment requests arrived (UI badge hint). + pub has_new_requests: AtomicBool, + /// Per-sender rate limiting state (unix seconds of accepted events). + rate: Mutex>>, + /// Current outgoing-send phase for the UI (see [`SendPhase`]). + send_phase: std::sync::atomic::AtomicU8, + /// Human-readable reason the last send/request/approve failed, surfaced on + /// the failure screen so the user (and we) can see WHY, not just "couldn't + /// send". Cleared when a new attempt starts. + last_send_error: RwLock>, + /// Result of the most recent manual payment-cancel, taken once by the receipt + /// UI to show "cancelled" vs "already went through". + cancel_notice: RwLock>, + /// Serializes a manual payment-cancel against a concurrent S2 finalize+post + /// so the two can't both succeed (cancel the outputs AND post on-chain). + cancel_finalize_lock: Mutex<()>, +} + +/// Phase of the most recent outgoing send, polled by the send UI. +pub mod send_phase { + pub const IDLE: u8 = 0; + pub const WORKING: u8 = 1; + pub const SENT: u8 = 2; + pub const FAILED: u8 = 3; + /// A request was refused up front because the recipient advertises that + /// they are not accepting incoming requests ("Could not request"). + pub const REQUEST_BLOCKED: u8 = 4; +} + +impl NostrService { + /// Create the service for an unlocked identity. + pub fn new( + keys: Keys, + identity: NostrIdentity, + config: NostrConfig, + store: NostrStore, + nostr_dir: PathBuf, + ) -> Arc { + Arc::new(Self { + keys, + identity: RwLock::new(identity), + config: RwLock::new(config), + store: Arc::new(store), + nostr_dir, + client: RwLock::new(None), + rt_handle: RwLock::new(None), + started: AtomicBool::new(false), + shutdown: AtomicBool::new(false), + connected: AtomicBool::new(false), + has_new_requests: AtomicBool::new(false), + rate: Mutex::new(HashMap::new()), + send_phase: std::sync::atomic::AtomicU8::new(send_phase::IDLE), + last_send_error: RwLock::new(None), + cancel_notice: RwLock::new(None), + cancel_finalize_lock: Mutex::new(()), + }) + } + + /// Own public key. + pub fn public_key(&self) -> PublicKey { + self.keys.public_key() + } + + /// Own npub bech32. + pub fn npub(&self) -> String { + self.identity.read().npub.clone() + } + + /// Shareable NIP-19 nprofile: our pubkey plus up to two of our relays as + /// routing hints, so a sender can reach us without any registry or + /// indexer lookup. Falls back to the bare npub when encoding fails. + pub fn nprofile(&self) -> String { + use nostr_sdk::RelayUrl; + use nostr_sdk::nips::nip19::Nip19Profile; + let relays: Vec = self + .relays() + .iter() + .filter_map(|r| RelayUrl::parse(r).ok()) + .take(2) + .collect(); + Nip19Profile::new(self.keys.public_key(), relays) + .to_bech32() + .ok() + .unwrap_or_else(|| self.npub()) + } + + /// Own nsec (secret key) bech32 — for explicit user backup only. + pub fn nsec(&self) -> Option { + self.keys.secret_key().to_bech32().ok() + } + + /// The service's signing keys, for in-process signing (e.g. NIP-98 auth) + /// without ever serializing the secret to a plaintext `String`. + pub fn keys(&self) -> Keys { + self.keys.clone() + } + + /// Fetch a pubkey's published kind-0 profile (one shot, short timeout). + /// `Some` means the key is a live nostr identity; `None` means no profile is + /// published (new/anonymous key) or the relays were unreachable. `hints` are + /// extra relays to dial first — the profile may live only on the target's own + /// relays (NIP-65/gossip), which we won't otherwise be connected to. Blocking; + /// call from a worker thread. + pub fn fetch_profile_blocking(&self, hex: &str, hints: &[String]) -> Option { + let client = self.client.read().clone()?; + let pk = PublicKey::from_hex(hex).ok()?; + let hints: Vec = hints.to_vec(); + // Run on the SERVICE runtime — the relay connections (and the custom Nym + // mixnet transport) live there. A throwaway current-thread runtime can't + // drive them, which is why bare-npub profile lookups silently returned + // nothing even though the relay serves the kind-0 fine. + let handle = self.rt_handle.read().clone()?; + let own_relays = self.relays(); + handle.block_on(async { + // Dial the target's own relays (hints) AND our own relay set so the + // kind-0 is reachable whether it lives on their relays or ours (most + // Goblin users share relay.goblin.st). Without this, a bare-npub scan + // only queried whatever happened to be connected and often saw nothing. + let mut dial: Vec = hints.clone(); + for r in &own_relays { + if !dial.contains(r) { + dial.push(r.clone()); + } + } + if !dial.is_empty() { + connect_relays(&client, &dial).await; + } + let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1); + let events = client + .fetch_events(filter, Duration::from_secs(10)) + .await + .ok()?; + let md: Metadata = serde_json::from_str(&events.first()?.content).ok()?; + Some(NostrProfile { + name: md.name.filter(|s| !s.is_empty()), + nip05: md.nip05.filter(|s| !s.is_empty()), + }) + }) + } + + /// Best-effort read of a pubkey's published "accepts requests" preference. + /// `Some(false)` = explicitly not accepting; `Some(true)`/`None` (no profile, + /// field absent, or relays unreachable) = treat as accepting. Async — safe to + /// call from the service runtime. Fail-open: only `Some(false)` blocks. + pub async fn accepts_requests(&self, hex: &str) -> Option { + let client = self.client.read().clone()?; + let pk = PublicKey::from_hex(hex).ok()?; + let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1); + let events = client + .fetch_events(filter, Duration::from_secs(8)) + .await + .ok()?; + let md: Metadata = serde_json::from_str(&events.first()?.content).ok()?; + md.custom + .get("goblin_accepts_requests") + .and_then(|v| v.as_bool()) + } + + /// Republish our kind-0 profile + kind-10050 DM relays (e.g. after toggling + /// the incoming-requests preference) so the change propagates immediately. + pub async fn republish_identity(self: &Arc) { + let client = { self.client.read().clone() }; + if let Some(client) = client { + publish_identity(self, &client).await; + } + } + + /// Read the current outgoing-send phase (see [`send_phase`]). + pub fn send_phase(&self) -> u8 { + self.send_phase.load(Ordering::Relaxed) + } + + /// Set the outgoing-send phase (called by the send task + UI). Starting a new + /// attempt (WORKING) clears any prior failure reason. + pub fn set_send_phase(&self, phase: u8) { + if phase == send_phase::WORKING { + *self.last_send_error.write() = None; + } + self.send_phase.store(phase, Ordering::Relaxed); + } + + /// Record why the current send/request/approve failed (shown on the failure + /// screen) and flip the phase to FAILED. + pub fn fail_send(&self, reason: impl Into) { + *self.last_send_error.write() = Some(reason.into()); + self.send_phase.store(send_phase::FAILED, Ordering::Relaxed); + } + + /// The reason the last send failed, if any. + pub fn last_send_error(&self) -> Option { + self.last_send_error.read().clone() + } + + /// Record the outcome of a manual payment-cancel for the UI to surface. + pub fn set_cancel_notice(&self, outcome: CancelOutcome) { + *self.cancel_notice.write() = Some(outcome); + } + + /// Take (consume) the pending payment-cancel outcome, if any. + pub fn take_cancel_notice(&self) -> Option { + self.cancel_notice.write().take() + } + + /// Acquire the cancel/finalize serialization lock. Held by both the manual + /// payment-cancel and `nostr_finalize_post` so a cancel and a concurrent S2 + /// finalize can't both commit (one would reclaim outputs the other posts). + pub fn lock_finalize(&self) -> parking_lot::MutexGuard<'_, ()> { + self.cancel_finalize_lock.lock() + } + + /// Whether at least one relay is connected. + pub fn is_connected(&self) -> bool { + self.connected.load(Ordering::Relaxed) + } + + /// Whether the service loop is running. + pub fn is_running(&self) -> bool { + self.started.load(Ordering::Relaxed) && !self.shutdown.load(Ordering::Relaxed) + } + + /// Save the identity file after mutation (e.g. NIP-05 registration). + pub fn save_identity(&self) { + let identity = self.identity.read().clone(); + if let Err(e) = identity.save(&self.nostr_dir) { + error!("nostr: identity save failed: {e}"); + } + } + + /// Start the service thread (idempotent). + pub fn start(self: &Arc, wallet: Wallet) { + if self.started.swap(true, Ordering::SeqCst) { + return; + } + let svc = self.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let svc_run = svc.clone(); + rt.block_on(async move { + run_service(svc_run, wallet).await; + }); + svc.started.store(false, Ordering::SeqCst); + svc.connected.store(false, Ordering::Relaxed); + info!("nostr: service stopped"); + }); + } + + /// Request the service loop to stop. + pub fn stop(&self) { + self.shutdown.store(true, Ordering::SeqCst); + } + + /// Restart with current config (relay list changes). + pub fn restart(self: &Arc, wallet: Wallet) { + self.stop(); + let svc = self.clone(); + thread::spawn(move || { + // Wait for the loop to exit, then start again. + while svc.started.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(300)); + } + svc.shutdown.store(false, Ordering::SeqCst); + svc.start(wallet); + }); + } + + /// Current relay list. + pub fn relays(&self) -> Vec { + self.config.read().relays() + } + + /// Auto-expire stale pending transactions after the configured window + /// (`NostrConfig::expiry_secs`, default 24h). A transaction that never + /// completed is canceled/expired: + /// - Outgoing sends and invoices we paid LOCK our outputs, so they are + /// cancelled at the wallet level (reusing GRIM's `cancel_tx` via + /// `WalletTask::Cancel`) to release those funds. + /// - Incoming payments and invoices we issued lock nothing of ours, so we + /// only annotate the metadata `Cancelled`; if a payment posts late, + /// on-chain confirmation still wins (the UI only shows "canceled" while + /// unconfirmed). + /// - Pending incoming requests become `Expired`. + /// + /// Runs from the wallet sync loop, so a lowered `expiry_secs` (set in + /// `nostr.toml` for testing) takes effect within a sync cycle. + pub fn expire_stale(&self, wallet: &Wallet) { + let now = unix_time(); + let window = self.config.read().expiry_secs(); + if window <= 0 { + return; + } + + let stale: Vec = self + .store + .all_tx_meta() + .into_iter() + .filter(|m| !expiry_terminal(m.status)) + .filter(|m| now - m.created_at > window) + .collect(); + + if !stale.is_empty() { + // Map slate uuid → wallet tx id once (public wallet data), so we can + // cancel the underlying GRIM tx for the funds-locking cases. + let tx_ids: HashMap = wallet + .get_data() + .and_then(|d| d.txs) + .map(|txs| { + txs.iter() + .filter_map(|t| t.data.tx_slate_id.map(|u| (u.to_string(), t.data.id))) + .collect() + }) + .unwrap_or_default(); + + for meta in stale { + // Only outgoing sends + invoices we paid lock our outputs. + if expiry_locks_outputs(meta.direction, meta.status) { + if let Some(&tx_id) = tx_ids.get(&meta.slate_id) { + info!( + "nostr: expiring stale send {} → cancel wallet tx {}", + meta.slate_id, tx_id + ); + wallet.task(WalletTask::Cancel(tx_id)); + } + } else { + info!( + "nostr: expiring stale {} ({:?})", + meta.slate_id, meta.direction + ); + } + self.store + .update_tx_status(&meta.slate_id, NostrSendStatus::Cancelled); + } + } + + // Incoming payment requests we never approved. + for req in self.store.pending_requests() { + if now - req.received_at > window { + info!("nostr: expiring stale incoming request {}", req.rumor_id); + self.store + .update_request_status(&req.rumor_id, RequestStatus::Expired); + } + } + } + + /// Sliding-window rate limiter, true when the event is allowed. + fn allow_sender(&self, sender: &str, is_contact: bool) -> bool { + let max = if is_contact { + RATE_CONTACT_PER_HOUR + } else { + RATE_UNKNOWN_PER_HOUR + }; + let now = unix_time(); + let mut rate = self.rate.lock(); + let hits = rate.entry(sender.to_string()).or_default(); + hits.retain(|t| now - *t < 3600); + if hits.len() >= max { + return false; + } + hits.push(now); + if rate.len() > 10_000 { + rate.retain(|_, v| v.iter().any(|t| now - *t < 3600)); + } + true + } + + /// Global ceiling on gift-wrap decrypt attempts across ALL senders. The + /// per-sender limit only kicks in after the (expensive) NIP-44 decrypt + /// reveals the sender, so an attacker minting unlimited fresh keypairs + /// would otherwise force unbounded decrypts. Bounds total decrypt work to + /// ~2/sec — far above any legitimate inbound rate. + fn allow_global_unwrap(&self) -> bool { + const GLOBAL_PER_MIN: usize = 120; + let now = unix_time(); + let mut rate = self.rate.lock(); + let hits = rate.entry("\0global".to_string()).or_default(); + hits.retain(|t| now - *t < 60); + if hits.len() >= GLOBAL_PER_MIN { + return false; + } + hits.push(now); + true + } + + /// Dispatch a payment DM (slatepack + optional note) to a recipient, + /// publishing to their DM relays plus our own relay set. `relay_hints` + /// are extra recipient relays carried by an nprofile the sender pasted + /// or scanned — the only routing info we have for a fresh recipient + /// whose kind 10050 isn't discoverable from our relays. + pub async fn send_payment_dm( + &self, + receiver_hex: &str, + slatepack: &str, + note: Option<&str>, + relay_hints: &[String], + ) -> Result { + let client = { + let r_client = self.client.read(); + r_client.clone().ok_or("nostr client is not running")? + }; + let receiver = + PublicKey::from_hex(receiver_hex).map_err(|e| format!("invalid receiver: {e}"))?; + let content = protocol::build_payment_content(slatepack); + let tags = protocol::build_rumor_tags(note); + + // Resolve receiver DM relays (kind 10050) with our relays as fallback. + let mut urls = self.fetch_dm_relays(&client, &receiver).await; + for r in relay_hints { + if !urls.contains(r) { + urls.push(r.clone()); + } + } + for r in self.relays() { + if !urls.contains(&r) { + urls.push(r); + } + } + + // NIP-17 delivers to the RECIPIENT's relays, which may differ from ours; + // dial any we don't already hold so the gift wrap actually reaches their + // inbox (otherwise `send_*_to` errors "relay not found" / never arrives). + connect_relays(&client, &urls).await; + + let res = tokio::time::timeout( + SEND_TIMEOUT, + client.send_private_msg_to(urls, receiver, content, tags), + ) + .await + .map_err(|_| "send timeout".to_string())? + .map_err(|e| format!("send failed: {e}"))?; + Ok(res.val.to_hex()) + } + + /// Dispatch a control DM that voids a pending request (a decline by the payer + /// or a cancel by the requester) to `receiver_hex`, referencing `slate_id`. + /// Same routing as a payment DM, but the message carries no slatepack. + pub async fn send_control_dm( + &self, + receiver_hex: &str, + slate_id: &str, + relay_hints: &[String], + ) -> Result { + let client = { + let r_client = self.client.read(); + r_client.clone().ok_or("nostr client is not running")? + }; + let receiver = + PublicKey::from_hex(receiver_hex).map_err(|e| format!("invalid receiver: {e}"))?; + let content = protocol::build_control_content(); + let tags = protocol::build_control_tags(slate_id); + + let mut urls = self.fetch_dm_relays(&client, &receiver).await; + for r in relay_hints { + if !urls.contains(r) { + urls.push(r.clone()); + } + } + for r in self.relays() { + if !urls.contains(&r) { + urls.push(r); + } + } + + connect_relays(&client, &urls).await; + + let res = tokio::time::timeout( + SEND_TIMEOUT, + client.send_private_msg_to(urls, receiver, content, tags), + ) + .await + .map_err(|_| "send timeout".to_string())? + .map_err(|e| format!("send failed: {e}"))?; + Ok(res.val.to_hex()) + } + + /// Fetch a contact's kind 10050 DM relay list from our relays. + async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> Vec { + // Use cached relays first. + if let Some(contact) = self.store.contact(&pk.to_hex()) { + if !contact.relays.is_empty() { + return contact.relays.into_iter().take(MAX_DM_RELAYS).collect(); + } + } + let filter = Filter::new().kind(Kind::InboxRelays).author(*pk).limit(1); + let mut out = vec![]; + if let Ok(events) = client.fetch_events(filter, FETCH_TIMEOUT).await { + if let Some(event) = events.first() { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("relay") { + if let Some(url) = parts.get(1) { + if out.len() < MAX_DM_RELAYS { + out.push(url.trim_end_matches('/').to_string()); + } + } + } + } + } + } + // Cache discovered relays on the contact when present. + if !out.is_empty() { + if let Some(mut contact) = self.store.contact(&pk.to_hex()) { + contact.relays = out.clone(); + self.store.save_contact(&contact); + } + } + out + } + + /// Ensure a contact entry exists for a sender (auto-added as unknown). + fn ensure_contact(&self, sender_hex: &str) { + if self.store.contact(sender_hex).is_none() { + // Guard the byte slice: callers pass 64-char hex today, but this is a + // general helper and a short/non-ASCII key must not panic. + let hue = sender_hex + .get(..2) + .and_then(|s| u8::from_str_radix(s, 16).ok()) + .unwrap_or(0) + % 7; + self.store.save_contact(&Contact { + ver: 1, + npub: sender_hex.to_string(), + petname: None, + nip05: None, + nip05_verified_at: None, + relays: vec![], + hue, + unknown: true, + added_at: unix_time(), + last_paid_at: None, + blocked: false, + }); + } + } + + /// Best-effort: resolve and KEEP FRESH a contact's published `@username`. + /// Incoming messages only carry the sender's key, so a fresh contact shows as + /// a bare npub; this fetches their kind-0, and if it advertises a NIP-05 that + /// maps back to their key, records it so the UI shows `@username`. It also + /// re-validates an already-known name (older than the freshness window): if + /// the server says the name was released or reassigned, it CLEARS it so the + /// stale name stops showing; a transient network miss leaves it untouched. + /// Spawns a worker; fail-open. A user-set petname is never touched. + pub fn resolve_contact_identity(self: &Arc, sender_hex: &str) { + let existing = self.store.contact(sender_hex); + // Freshness gate: skip only if a name was verified recently. Older (or + // never-verified) contacts are (re-)checked so releases get caught. + if let Some(c) = &existing { + if let (Some(_), Some(at)) = (&c.nip05, c.nip05_verified_at) { + if unix_time() - at < NAME_REVERIFY_INTERVAL_SECS { + return; + } + } + } + // Any DM relays we've already learned for them are the best hint for where + // their profile lives (their messages came from there). + let hints = existing + .as_ref() + .map(|c| c.relays.clone()) + .unwrap_or_default(); + let cached_nip05 = existing.and_then(|c| c.nip05); + let svc = self.clone(); + let hex = sender_hex.to_string(); + thread::spawn(move || { + let Ok(pk) = PublicKey::from_hex(&hex) else { + return; + }; + let Ok(rt) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + // Primary: ask the home authority directly what @name this key holds. + // One HTTP round-trip, authoritative, and independent of whether we can + // fetch their kind-0 off a relay (the fragile leg) — this is what + // makes a contact's name show on the FIRST interaction. + let home = crate::nostr::nip05::home_domain(); + if let Some(name) = rt.block_on(crate::nostr::nip05::name_by_pubkey(&home, &hex)) { + let nip05 = format!("{}@{}", name, home); + if let Some(mut c) = svc.store.contact(&hex) { + if apply_nip05_check(&mut c, &nip05, crate::nostr::nip05::Nip05Check::Verified) + { + svc.store.save_contact(&c); + } + } + return; + } + // Fallback: the handle they advertise in their kind-0 (covers FOREIGN + // authorities the home reverse-lookup can't speak for); if the kind-0 can't + // be fetched, fall back to the cached handle so a release is still caught. + // This path can also CLEAR a released/reassigned name. + let advertised = svc + .fetch_profile_blocking(&hex, &hints) + .and_then(|p| p.nip05); + let Some(nip05) = advertised.or(cached_nip05) else { + return; // anonymous and nothing cached — nothing to check + }; + let Some((name, domain)) = nip05.split_once('@') else { + return; + }; + let check = rt.block_on(crate::nostr::nip05::check(&pk, name, domain)); + if let Some(mut c) = svc.store.contact(&hex) { + if apply_nip05_check(&mut c, &nip05, check) { + svc.store.save_contact(&c); + } + } + }); + } +} + +/// Apply a name re-check outcome to a contact in place; returns true if it +/// changed and should be saved. `Verified` records/refreshes the handle; +/// `Mismatch` (released or reassigned) clears it so the npub takes over; +/// `Unreachable` leaves it alone. A user-set petname is never touched. +fn apply_nip05_check(c: &mut Contact, nip05: &str, check: crate::nostr::nip05::Nip05Check) -> bool { + use crate::nostr::nip05::Nip05Check; + match check { + Nip05Check::Verified => { + c.nip05 = Some(nip05.to_string()); + c.nip05_verified_at = Some(unix_time()); + true + } + Nip05Check::Mismatch => { + let had = c.nip05.is_some() || c.nip05_verified_at.is_some(); + c.nip05 = None; + c.nip05_verified_at = None; + had + } + Nip05Check::Unreachable => false, + } +} + +/// Main service loop: connect, publish identity, catch up, listen. +async fn run_service(svc: Arc, wallet: Wallet) { + // Publish the service runtime handle so worker-thread one-shots (profile + // lookups) can run their fetches here, where the relay I/O actually lives. + *svc.rt_handle.write() = Some(tokio::runtime::Handle::current()); + // Mirror the configured name authority so resolution + display follow it. + crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain()); + let relays = svc.relays(); + info!( + "nostr: starting service for {} with relays {:?}", + svc.npub(), + relays + ); + + let client = Client::builder() + .signer(svc.keys.clone()) + .websocket_transport(NymWebSocketTransport) + .build(); + for relay in &relays { + if let Err(e) = client.add_relay(relay.clone()).await { + warn!("nostr: add relay {relay} failed: {e}"); + } + } + // Wait for the in-process Nym SOCKS5 proxy (:1080) before dialing relays. + // `warm_up()` starts it at launch, but a fast wallet-open can beat the cold + // mixnet bootstrap — and dialing before it's up drops every relay into + // nostr-sdk's backing-off reconnect, leaving the wallet on "Connecting…" long + // after the mixnet is actually ready. Once it's warm this returns immediately. + for i in 0..60u32 { + if nym_socks_ready().await { + if i > 0 { + info!( + "nostr: Nym proxy ready after ~{}ms, dialing relays", + i * 500 + ); + } + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + let connect_started = std::time::Instant::now(); + client.connect().await; + { + let mut w_client = svc.client.write(); + *w_client = Some(client.clone()); + } + + // Log when the first relay reaches Connected over the mixnet, measured from + // the connect() call. Non-blocking; exits on first success. + { + let client_probe = client.clone(); + let svc_probe = svc.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + if relays_connected(&client_probe).await { + info!( + "nostr: first relay Connected ~{}ms after connect()", + connect_started.elapsed().as_millis() + ); + return; + } + if svc_probe.shutdown.load(Ordering::SeqCst) + || connect_started.elapsed() > Duration::from_secs(150) + { + warn!( + "nostr: no relay Connected within {}ms of connect()", + connect_started.elapsed().as_millis() + ); + return; + } + } + }); + } + + // Publish identity events (kind 10050 DM relays; kind 0 only when named). + publish_identity(&svc, &client).await; + + // Catch-up + live subscription for our gift wraps. + let since = svc + .store + .last_connected_at() + .map(|t| t - LOOKBACK_SECS) + .unwrap_or_else(|| unix_time() - LOOKBACK_SECS) + .max(0) as u64; + let filter = Filter::new() + .kind(Kind::GiftWrap) + .pubkey(svc.public_key()) + .since(Timestamp::from_secs(since)); + + if let Ok(events) = client.fetch_events(filter.clone(), FETCH_TIMEOUT).await { + info!("nostr: catch-up fetched {} wraps", events.len()); + for event in events.into_iter() { + handle_wrap(&svc, &wallet, &client, event).await; + } + } + if let Err(e) = client.subscribe(filter, None).await { + error!("nostr: subscribe failed: {e}"); + } + + // Re-dispatch pending outgoing messages after restart. + reconcile(&svc, &wallet).await; + + // Backfill @usernames for contacts we only know by npub (e.g. from before + // this resolved on every interaction), so activity shows names not keys. + for contact in svc.store.all_contacts() { + if contact.nip05.is_none() || contact.nip05_verified_at.is_none() { + svc.resolve_contact_identity(&contact.npub); + } + } + + svc.store.set_last_connected_at(unix_time()); + svc.store.prune_processed(); + + // Reflect the connection the moment we reach the loop instead of leaving the + // UI on "Connecting…" until the first heartbeat — by now catch-up has run, so + // a relay is typically already up. + svc.connected + .store(relays_connected(&client).await, Ordering::Relaxed); + + let mut notifications = client.notifications(); + // Poll connection state on a SHORT, INDEPENDENT interval. This used to live in + // the `select!` behind a `sleep(30s)` that restarted on every notification, so + // the flag could lag the real relay state by 30s+ (or, under steady event + // flow, never update) — that's the "stuck on Connecting…" the mixnet gets + // blamed for, even though a relay handshake over Nym takes ~2s. An `interval` + // fires on its own schedule regardless of notifications; the heavier heartbeat + // work (persisting last-seen, TTL pruning) stays on a ~30s cadence. + let mut status_tick = tokio::time::interval(Duration::from_secs(2)); + status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last_heartbeat = unix_time(); + let mut last_prune = unix_time(); + // Seed from the persisted sweep time, NOT now: a fresh launch should re-check + // names right away (so you see refreshed info from app open), unless one ran + // within the last interval. + let mut last_name_sweep = svc.store.last_name_sweep_at().unwrap_or(0); + loop { + if svc.shutdown.load(Ordering::SeqCst) || !wallet.is_open() { + break; + } + tokio::select! { + notification = notifications.recv() => { + match notification { + Ok(RelayPoolNotification::Event { event, .. }) => { + handle_wrap(&svc, &wallet, &client, *event).await; + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!("nostr: notifications lagged by {n}"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + _ = status_tick.tick() => { + svc.connected + .store(relays_connected(&client).await, Ordering::Relaxed); + let now = unix_time(); + if now - last_heartbeat >= 30 { + last_heartbeat = now; + svc.store.set_last_connected_at(now); + if now - last_prune >= 3600 { + svc.store.prune_processed(); + last_prune = now; + } + } + // Re-validate cached @usernames so a released/reassigned name + // stops showing. Only the stalest few per sweep (capped) to bound + // mixnet lookups; each worker re-checks against the identity server. + // Skipped while the app is backgrounded — no point spending mixnet + // round-trips when nobody's looking. We DON'T advance last_name_sweep + // in that case, so the very next foreground tick runs the sweep + // immediately to catch up on resume. + if now - last_name_sweep >= NAME_REVERIFY_INTERVAL_SECS && crate::app_foreground() { + last_name_sweep = now; + svc.store.set_last_name_sweep_at(now); + let mut due: Vec<_> = svc + .store + .all_contacts() + .into_iter() + .filter(|c| { + c.nip05.is_some() + && c.nip05_verified_at + .map(|at| now - at >= NAME_REVERIFY_INTERVAL_SECS) + .unwrap_or(true) + }) + .collect(); + // Stalest first (oldest verification), so a big list rolls through. + due.sort_by_key(|c| c.nip05_verified_at.unwrap_or(0)); + for c in due.into_iter().take(NAME_REVERIFY_MAX_PER_TICK) { + svc.resolve_contact_identity(&c.npub); + } + } + } + } + } + + { + let mut w_client = svc.client.write(); + *w_client = None; + } + client.disconnect().await; +} + +/// Quick, non-blocking check that the Nym SOCKS5 proxy is accepting +/// connections on its loopback port (i.e. the mixnet is ready to carry traffic). +async fn nym_socks_ready() -> bool { + matches!( + tokio::time::timeout( + Duration::from_millis(500), + tokio::net::TcpStream::connect(crate::nym::socks5_addr()), + ) + .await, + Ok(Ok(_)) + ) +} + +/// Add + dial every relay in `urls` so a targeted send reaches relays we don't +/// already hold (NIP-65/gossip: the recipient's relays may differ from ours). +/// `add_relay` is idempotent and `try_connect_relay` returns once connected or +/// the timeout lapses; dialed concurrently so a slow relay doesn't stall the rest. +async fn connect_relays(client: &Client, urls: &[String]) { + let dials = urls.iter().map(|url| { + let url = url.clone(); + async move { + let _ = client.add_relay(&url).await; + // Short cap: a reachable relay connects in ~2-4s over the mixnet; we + // don't want one dead relay in the list to stall the whole send. Once + // connected it stays connected, so only the first send pays this. + let _ = client.try_connect_relay(&url, Duration::from_secs(6)).await; + } + }); + futures::future::join_all(dials).await; +} + +/// True when at least one relay has completed its handshake. +async fn relays_connected(client: &Client) -> bool { + client + .relays() + .await + .values() + .any(|r| r.status() == RelayStatus::Connected) +} + +/// Publish kind 10050 DM relay list and, for named identities, kind 0 metadata. +async fn publish_identity(svc: &Arc, client: &Client) { + let relays = svc.relays(); + let dm_tags: Vec = relays + .iter() + .take(MAX_DM_RELAYS) + .map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()])) + .collect(); + let builder = EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags); + if let Err(e) = client.send_event_builder(builder).await { + warn!("nostr: publish 10050 failed: {e}"); + } + + let (anonymous, nip05) = { + let identity = svc.identity.read(); + (identity.anonymous, identity.nip05.clone()) + }; + if !anonymous { + if let Some(nip05) = nip05 { + let name = nip05.split('@').next().unwrap_or_default().to_string(); + // Advertise the request opt-out so requesters see it before sending. + let allow_requests = svc.config.read().allow_incoming_requests(); + let metadata = Metadata::new() + .name(name) + .nip05(nip05) + .custom_field("goblin_accepts_requests", allow_requests); + let builder = EventBuilder::metadata(&metadata); + if let Err(e) = client.send_event_builder(builder).await { + warn!("nostr: publish kind 0 failed: {e}"); + } + } + } +} + +/// A transaction in a terminal state never expires (already done or canceled). +fn expiry_terminal(status: NostrSendStatus) -> bool { + matches!( + status, + NostrSendStatus::Finalized | NostrSendStatus::Cancelled + ) +} + +/// Whether an expired transaction with this (direction, status) locked OUR +/// outputs and therefore needs a wallet-level `cancel_tx` to release them +/// (outgoing sends and invoices we paid). Incoming payments and invoices we +/// issued lock nothing of ours, so those are only annotated as canceled. +fn expiry_locks_outputs(direction: NostrTxDirection, status: NostrSendStatus) -> bool { + matches!( + (direction, status), + (NostrTxDirection::Sent, NostrSendStatus::Created) + | (NostrTxDirection::Sent, NostrSendStatus::AwaitingS2) + | (NostrTxDirection::Sent, NostrSendStatus::SendFailed) + | ( + NostrTxDirection::RequestedOfUs, + NostrSendStatus::PaidAwaitingFinalize + ) + ) +} + +/// Re-dispatch our pending outgoing messages (crash/offline recovery). +async fn reconcile(svc: &Arc, wallet: &Wallet) { + let now = unix_time(); + for meta in svc.store.all_tx_meta() { + if now - meta.created_at > RESEND_WINDOW_SECS { + continue; + } + let resend_state = match (meta.direction, meta.status) { + // S1 never dispatched or failed. + (NostrTxDirection::Sent, NostrSendStatus::Created) + | (NostrTxDirection::Sent, NostrSendStatus::SendFailed) => { + Some(grin_wallet_libwallet::SlateState::Standard1) + } + // I1 request never dispatched or failed. + (NostrTxDirection::RequestedByUs, NostrSendStatus::Created) + | (NostrTxDirection::RequestedByUs, NostrSendStatus::SendFailed) => { + Some(grin_wallet_libwallet::SlateState::Invoice1) + } + // We received and processed S1 but the S2 reply may not have left. + (NostrTxDirection::Received, NostrSendStatus::ReceivedNoReply) => { + Some(grin_wallet_libwallet::SlateState::Standard2) + } + // We paid a request (I2) but the reply may not have left. + (NostrTxDirection::RequestedOfUs, NostrSendStatus::ReceivedNoReply) => { + Some(grin_wallet_libwallet::SlateState::Invoice2) + } + _ => None, + }; + let Some(state) = resend_state else { continue }; + let Ok(slate_id) = uuid::Uuid::parse_str(&meta.slate_id) else { + continue; + }; + let Some(text) = wallet.read_slatepack_text(slate_id, &state) else { + continue; + }; + info!( + "nostr: reconcile re-dispatch {} ({:?})", + meta.slate_id, state + ); + match svc + .send_payment_dm(&meta.npub, &text, meta.note.as_deref(), &[]) + .await + { + Ok(event_id) => { + let mut updated = meta.clone(); + updated.sent_event_id = Some(event_id); + updated.status = match state { + grin_wallet_libwallet::SlateState::Standard1 => NostrSendStatus::AwaitingS2, + grin_wallet_libwallet::SlateState::Invoice1 => NostrSendStatus::AwaitingI2, + grin_wallet_libwallet::SlateState::Standard2 => NostrSendStatus::RepliedS2, + _ => NostrSendStatus::PaidAwaitingFinalize, + }; + updated.updated_at = unix_time(); + svc.store.save_tx_meta(&updated); + } + Err(e) => warn!( + "nostr: reconcile dispatch failed for {}: {e}", + meta.slate_id + ), + } + } +} + +/// Full guarded pipeline for one incoming gift wrap event. +/// Apply a request-void control message. Two roles, distinguished by what we +/// hold for `slate_id`; in both the `sender` must match the stored counterparty, +/// so an attacker can't void a request they're not party to. +fn handle_request_void(svc: &Arc, wallet: &Wallet, slate_id: &str, sender: &str) { + // Role A — we are the payer and the requester withdrew. Drop the pending card. + let mut voided = false; + for req in svc.store.pending_requests() { + if req.slate_id == slate_id && req.npub == sender { + info!( + "nostr: incoming request {} withdrawn by requester", + req.rumor_id + ); + svc.store + .update_request_status(&req.rumor_id, RequestStatus::Cancelled); + svc.has_new_requests.store(true, Ordering::Relaxed); + voided = true; + } + } + if voided { + return; + } + // The `sender` must match the stored counterparty (binding checked below) so + // a stranger can't void someone else's tx. + let Some(meta) = svc.store.tx_meta(slate_id) else { + return; + }; + if meta.npub != sender { + return; + } + match (meta.direction, meta.status) { + // Role B — we are the requester and the payer declined our invoice. An + // issued invoice locks no outputs of ours, so cancelling the grin tx is + // safe and keeps the ledger tidy. + (NostrTxDirection::RequestedByUs, NostrSendStatus::Created) + | (NostrTxDirection::RequestedByUs, NostrSendStatus::AwaitingI2) => { + info!("nostr: outgoing request {slate_id} declined by payer"); + if let Some(tx_id) = wallet.get_data().and_then(|d| d.txs).and_then(|txs| { + txs.iter() + .find(|t| { + t.data.tx_slate_id.map(|u| u.to_string()).as_deref() == Some(slate_id) + }) + .map(|t| t.data.id) + }) { + wallet.task(WalletTask::Cancel(tx_id)); + } + svc.store + .update_tx_status(slate_id, NostrSendStatus::Cancelled); + } + // Role C — we received a payment the SENDER now says is void. Only mark + // the meta cancelled for display; do NOT cancel the grin tx. Cancelling a + // received tx DELETES our incoming output from wallet tracking, and a + // malicious sender could void-then-still-finalize (they hold our S2 once + // we replied), confirming funds our wallet would no longer see. Leaving + // the output tracked means it still confirms if they post; if they don't, + // it simply never confirms (and shows Cancelled while unconfirmed). + (NostrTxDirection::Received, NostrSendStatus::ReceivedNoReply) + | (NostrTxDirection::Received, NostrSendStatus::RepliedS2) => { + info!("nostr: incoming payment {slate_id} voided by sender"); + svc.store + .update_tx_status(slate_id, NostrSendStatus::Cancelled); + } + _ => {} + } +} + +async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, event: Event) { + // 0. Only gift wraps. + if event.kind != Kind::GiftWrap { + return; + } + let wrap_id = event.id.to_hex(); + // 1. Cheap size cap before any crypto. + if event.content.len() > protocol::MAX_WRAP_CONTENT { + svc.store.mark_processed(&wrap_id); + return; + } + // 2. Wrap-level dedupe. + if svc.store.is_processed(&wrap_id) { + return; + } + // 2.5 Global decrypt ceiling: bound total NIP-44 unwrap work regardless of + // sender, so fresh-keypair spam can't burn unbounded CPU/battery. Not marked + // processed — a genuine backlog re-attempts once the window reopens. + if !svc.allow_global_unwrap() { + return; + } + // 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed). + let unwrapped = match client.unwrap_gift_wrap(&event).await { + Ok(u) => u, + Err(_) => { + svc.store.mark_processed(&wrap_id); + return; + } + }; + let sender = unwrapped.sender; + let mut rumor = unwrapped.rumor; + // 4. The rumor author must be the seal signer (NIP-17 requirement). + if rumor.pubkey != sender { + warn!("nostr: rumor author differs from seal signer, dropping"); + svc.store.mark_processed(&wrap_id); + return; + } + // Ignore our own messages (e.g. wrap-to-self copies). + if sender == svc.public_key() { + svc.store.mark_processed(&wrap_id); + return; + } + // 5. Only kind 14 with bounded content. + if rumor.kind != Kind::PrivateDirectMessage || rumor.content.len() > protocol::MAX_RUMOR_CONTENT + { + svc.store.mark_processed(&wrap_id); + return; + } + let sender_hex = sender.to_hex(); + // Blocked sender: drop silently, a nostr-level mute. Mark processed so we + // don't reconsider it on every catch-up. + if svc + .store + .contact(&sender_hex) + .map(|c| c.blocked) + .unwrap_or(false) + { + svc.store.mark_processed(&wrap_id); + return; + } + let is_contact = svc + .store + .contact(&sender_hex) + .map(|c| !c.unknown) + .unwrap_or(false); + // 6. Rate limit per sender. + if !svc.allow_sender(&sender_hex, is_contact) { + // Deliberately NOT marked processed: legitimate bursts can retry later. + return; + } + // 7. Rumor-level dedupe (the same rumor can arrive in different wraps). + let rumor_id = rumor.id().to_hex(); + if svc.store.is_processed(&rumor_id) { + svc.store.mark_processed(&wrap_id); + return; + } + // 8. Request-void control message (a decline by the payer or a cancel by the + // requester): it carries no slatepack, just an action tag naming a slate id. + // Handle it before slatepack extraction; the sender is bound to the stored + // counterparty inside, so a stranger can't void someone else's request. + if let Some(void_slate_id) = protocol::extract_control(&rumor.tags) { + handle_request_void(svc, wallet, &void_slate_id, &sender_hex); + // A decline/cancel is still an interaction with a known counterparty — + // (re)resolve their @name so it never drops to a bare npub just because the + // request didn't go through. Cheap, authoritative (reverse lookup), and a + // no-op for anonymous keys. + svc.resolve_contact_identity(&sender_hex); + // Record the void keyed by (slate, sender) so a payment S1 that arrives + // AFTER its void (relays reorder; NIP-59 randomizes timestamps) is dropped. + // Binding to the sender stops a stranger pre-voiding someone else's slate. + // A slate id is a UUID (36 chars); ignore anything longer so an attacker + // can't bloat the processed-key store with an oversized tag value. + if void_slate_id.len() <= 64 { + svc.store + .mark_processed(&format!("void:{}:{}", void_slate_id, sender_hex)); + } + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + return; + } + // 8b. Extract the slatepack; non-payment DMs are ignored entirely. + let Some(armor) = protocol::extract_slatepack(&rumor.content) else { + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + return; + }; + let note = protocol::extract_subject(&rumor.tags); + // 9. Parse and validate the slate itself. + let Ok((slate, _)) = wallet.parse_slatepack(&armor) else { + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + return; + }; + // 10. Slate-level dedupe. + let slate_marker = format!("slate:{}:{}", slate.id, slate.state); + if svc.store.is_processed(&slate_marker) { + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + return; + } + // 10b. Void-before-payment: the sender cancelled this payment and the void + // reached us before the S1. Drop the dead slate rather than auto-receiving it. + if matches!(slate.state, grin_wallet_libwallet::SlateState::Standard1) + && svc + .store + .is_processed(&format!("void:{}:{}", slate.id, sender_hex)) + { + info!( + "nostr: dropping S1 for slate {} already voided by sender", + slate.id + ); + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + svc.store.mark_processed(&slate_marker); + return; + } + // 11. Policy decision. + let meta = svc.store.tx_meta(&slate.id.to_string()); + let tx_exists = wallet.has_tx_for_slate(&slate.id); + let accept = svc.config.read().accept_from(); + let allow_requests = svc.config.read().allow_incoming_requests(); + let decision = decide(&IngestContext { + state: slate.state.clone(), + amount: slate.amount, + sender: &sender_hex, + meta: meta.as_ref(), + tx_exists, + is_contact, + accept, + allow_requests, + }); + info!( + "nostr: wrap {} slate {} state {} from {}…: {:?}", + &wrap_id[..8], + slate.id, + slate.state, + &sender_hex[..8], + decision + ); + + match decision { + IngestDecision::AutoReceive => { + svc.ensure_contact(&sender_hex); + // Resolve the sender's @username so the receive shows their name in + // activity, not a bare npub. + svc.resolve_contact_identity(&sender_hex); + match wallet.nostr_receive(&slate) { + Ok((_, reply_text)) => { + // Record BEFORE dispatching the reply: crash here is + // recovered by reconcile() re-sending the S2 from disk. + let now = unix_time(); + svc.store.save_tx_meta(&TxNostrMeta { + ver: 1, + slate_id: slate.id.to_string(), + npub: sender_hex.clone(), + direction: NostrTxDirection::Received, + note: note.clone(), + status: NostrSendStatus::ReceivedNoReply, + sent_event_id: None, + received_rumor_id: Some(rumor_id.clone()), + created_at: now, + updated_at: now, + }); + // Commit dedup markers now the receive is durable, BEFORE + // the reply + sync tail. A crash there must not let this + // wrap re-trigger a second receive on catch-up (decide() + // and grin's TransactionAlreadyReceived also backstop it). + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + svc.store.mark_processed(&slate_marker); + match svc + .send_payment_dm(&sender_hex, &reply_text, None, &[]) + .await + { + Ok(event_id) => { + if let Some(mut meta) = svc.store.tx_meta(&slate.id.to_string()) { + meta.status = NostrSendStatus::RepliedS2; + meta.sent_event_id = Some(event_id); + meta.updated_at = unix_time(); + svc.store.save_tx_meta(&meta); + } + } + Err(e) => warn!("nostr: S2 reply dispatch failed: {e}"), + } + wallet.sync(); + } + Err(e) => { + error!("nostr: receive failed for slate {}: {:?}", slate.id, e); + } + } + } + IngestDecision::SurfaceIncoming | IngestDecision::SurfaceRequest => { + svc.ensure_contact(&sender_hex); + // Resolve the requester's @username so the card isn't a bare npub. + svc.resolve_contact_identity(&sender_hex); + svc.store.save_request(&PaymentRequest { + ver: 1, + rumor_id: rumor_id.clone(), + slate_id: slate.id.to_string(), + slatepack: armor.clone(), + npub: sender_hex.clone(), + amount: slate.amount, + note: note.clone(), + received_at: unix_time(), + status: RequestStatus::Pending, + }); + svc.has_new_requests.store(true, Ordering::Relaxed); + // The request is durably saved — safe to mark this wrap processed. + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + svc.store.mark_processed(&slate_marker); + } + IngestDecision::FinalizePost => { + // The payer's reply is our first contact with their key on this side of + // a request we sent — make sure they're a known contact and resolve their + // @username so the completed request shows their name, not a bare npub. + svc.ensure_contact(&sender_hex); + svc.resolve_contact_identity(&sender_hex); + match wallet.nostr_finalize_post(&slate) { + Ok(true) => { + svc.store + .update_tx_status(&slate.id.to_string(), NostrSendStatus::Finalized); + // Finalize+post committed; mark dedup before the sync tail so a + // crash can't re-finalize on catch-up (grin rejects a second + // finalize and the meta is now Finalized, which decide() drops — + // this just avoids the redundant attempt). + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + svc.store.mark_processed(&slate_marker); + if let Some(mut contact) = svc.store.contact(&sender_hex) { + contact.last_paid_at = Some(unix_time()); + svc.store.save_contact(&contact); + } + wallet.sync(); + } + Ok(false) => { + // The send was cancelled out-of-band (the meta usually already + // reflects this and decide() drops the S2 before we get here; this + // covers a tx-list cancel that left the meta untouched). Reconcile + // the status and treat the reply as handled — never retry/re-post. + svc.store + .update_tx_status(&slate.id.to_string(), NostrSendStatus::Cancelled); + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + svc.store.mark_processed(&slate_marker); + info!("nostr: skipped finalize of cancelled slate {}", slate.id); + } + Err(e) => { + error!("nostr: finalize failed for slate {}: {:?}", slate.id, e); + } + } + } + IngestDecision::Drop(reason) => { + info!("nostr: dropped slate {}: {}", slate.id, reason); + // A dropped slate is a permanent decision — don't re-evaluate it. + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + svc.store.mark_processed(&slate_marker); + } + } + // NOTE: AutoReceive and FinalizePost mark the wrap processed only inside their + // success arms. On a transient failure they deliberately leave it UNMARKED so + // the next catch-up fetch retries — otherwise an incoming payment could be + // silently lost on a momentary wallet/node hiccup. decide() + grin's + // already-received / re-post guards keep a retried success idempotent. +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_contact() -> Contact { + Contact { + ver: 1, + npub: "abc".to_string(), + petname: Some("Mom".to_string()), + nip05: Some("ada@goblin.st".to_string()), + nip05_verified_at: Some(1000), + relays: vec![], + hue: 0, + unknown: false, + added_at: 1, + last_paid_at: None, + blocked: false, + } + } + + #[test] + fn name_recheck_clears_on_mismatch_keeps_petname() { + use crate::nostr::nip05::Nip05Check; + // Released/reassigned → clear the handle, but never the user's petname. + let mut c = sample_contact(); + assert!(apply_nip05_check( + &mut c, + "ada@goblin.st", + Nip05Check::Mismatch + )); + assert_eq!(c.nip05, None); + assert_eq!(c.nip05_verified_at, None); + assert_eq!(c.petname.as_deref(), Some("Mom")); + + // Unreachable → no change at all (don't drop a good name on a blip). + let mut c = sample_contact(); + assert!(!apply_nip05_check( + &mut c, + "ada@goblin.st", + Nip05Check::Unreachable + )); + assert_eq!(c.nip05.as_deref(), Some("ada@goblin.st")); + assert_eq!(c.nip05_verified_at, Some(1000)); + + // Verified → record the handle and refresh the timestamp. + let mut c = sample_contact(); + c.nip05 = None; + c.nip05_verified_at = None; + assert!(apply_nip05_check( + &mut c, + "bob@goblin.st", + Nip05Check::Verified + )); + assert_eq!(c.nip05.as_deref(), Some("bob@goblin.st")); + assert!(c.nip05_verified_at.is_some()); + + // Mismatch on an already-nameless contact → nothing to do. + let mut c = sample_contact(); + c.nip05 = None; + c.nip05_verified_at = None; + assert!(!apply_nip05_check( + &mut c, + "ada@goblin.st", + Nip05Check::Mismatch + )); + } + + #[test] + fn terminal_states_do_not_expire() { + assert!(expiry_terminal(NostrSendStatus::Finalized)); + assert!(expiry_terminal(NostrSendStatus::Cancelled)); + // Everything in flight is eligible to expire. + for s in [ + NostrSendStatus::Created, + NostrSendStatus::AwaitingS2, + NostrSendStatus::ReceivedNoReply, + NostrSendStatus::RepliedS2, + NostrSendStatus::AwaitingI2, + NostrSendStatus::PaidAwaitingFinalize, + NostrSendStatus::SendFailed, + ] { + assert!(!expiry_terminal(s), "{s:?} should be expirable"); + } + } + + #[test] + fn only_our_committed_outputs_get_cancelled() { + use NostrSendStatus::*; + use NostrTxDirection::*; + // Our sends (we locked outputs) and invoices we paid → cancel to unlock. + assert!(expiry_locks_outputs(Sent, Created)); + assert!(expiry_locks_outputs(Sent, AwaitingS2)); + assert!(expiry_locks_outputs(Sent, SendFailed)); + assert!(expiry_locks_outputs(RequestedOfUs, PaidAwaitingFinalize)); + // Incoming payments and invoices we issued lock nothing of ours → + // annotate only, never cancel a tx that could still settle/pay. + assert!(!expiry_locks_outputs(Received, ReceivedNoReply)); + assert!(!expiry_locks_outputs(Received, RepliedS2)); + assert!(!expiry_locks_outputs(RequestedByUs, AwaitingI2)); + assert!(!expiry_locks_outputs(RequestedByUs, Created)); + assert!(!expiry_locks_outputs(RequestedOfUs, ReceivedNoReply)); + } +} diff --git a/src/nostr/config.rs b/src/nostr/config.rs new file mode 100644 index 00000000..ff7eb2ee --- /dev/null +++ b/src/nostr/config.rs @@ -0,0 +1,177 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Per-wallet nostr configuration, stored as `nostr.toml` in the wallet dir. + +use serde_derive::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::Settings; +use crate::nostr::relays::{DEFAULT_NIP05_SERVER, DEFAULT_RELAYS}; + +/// Policy for accepting incoming payments (Standard1 slates). +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum AcceptPolicy { + /// Accept payments from anyone automatically (default, instant-pay feel). + Everyone, + /// Auto-accept contacts, surface unknown senders for approval. + Contacts, + /// Surface every incoming payment for approval. + Ask, +} + +/// Per-wallet nostr configuration. +#[derive(Serialize, Deserialize, Clone)] +pub struct NostrConfig { + /// Whether the nostr subsystem runs for this wallet. + enabled: Option, + /// Relay list override. + relays: Option>, + /// Accept policy for incoming payments. + accept_from: Option, + /// NIP-05 identity server base URL. + nip05_server: Option, + /// Seconds after which a still-pending transaction is auto-canceled/expired. + /// Default 24h; lower it (e.g. 60) in nostr.toml to test the expiry flow. + expiry_secs: Option, + /// Seconds before the manual "Cancel payment" button appears on a still- + /// pending send (one that never reached a relay shows it immediately). + /// Default 10 min; lower it in nostr.toml to test the cancel flow. + cancel_grace_secs: Option, + /// Whether incoming payment requests (Invoice1) are accepted. Opt-out: on + /// by default. When off, incoming requests are dropped and the preference is + /// advertised in our kind-0 profile so requesters see it before sending. + allow_incoming_requests: Option, + + /// Path of the config file, not serialized. + #[serde(skip)] + path: Option, +} + +impl Default for NostrConfig { + fn default() -> Self { + Self { + enabled: None, + relays: None, + accept_from: None, + nip05_server: None, + expiry_secs: None, + cancel_grace_secs: None, + allow_incoming_requests: None, + path: None, + } + } +} + +impl NostrConfig { + /// Nostr configuration file name inside the wallet directory. + pub const FILE_NAME: &'static str = "nostr.toml"; + + /// Load the config from the wallet directory, falling back to defaults. + pub fn load(wallet_dir: PathBuf) -> Self { + let mut path = wallet_dir; + path.push(Self::FILE_NAME); + let mut config: Self = Settings::read_from_file(path.clone()).unwrap_or_default(); + config.path = Some(path); + config + } + + /// Save the config to disk. + pub fn save(&self) { + if let Some(path) = &self.path { + Settings::write_to_file(self, path.clone()); + } + } + + pub fn enabled(&self) -> bool { + self.enabled.unwrap_or(true) + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + self.save(); + } + + pub fn relays(&self) -> Vec { + self.relays + .clone() + .filter(|r| !r.is_empty()) + .unwrap_or_else(|| DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()) + } + + pub fn set_relays(&mut self, relays: Vec) { + self.relays = Some(relays); + self.save(); + } + + pub fn accept_from(&self) -> AcceptPolicy { + self.accept_from.unwrap_or(AcceptPolicy::Everyone) + } + + pub fn set_accept_from(&mut self, policy: AcceptPolicy) { + self.accept_from = Some(policy); + self.save(); + } + + pub fn nip05_server(&self) -> String { + self.nip05_server + .clone() + .unwrap_or_else(|| DEFAULT_NIP05_SERVER.to_string()) + } + + /// The name-authority HOST derived from the configured server URL (e.g. + /// `goblin.st`). This is "home": bare names (`alice`) resolve here and own/ + /// home-domain names display without their domain. Federation: a different + /// authority makes `alice` mean `alice@thatdomain`, while a full + /// `bob@goblin.st` always resolves against goblin.st. + pub fn home_domain(&self) -> String { + let server = self.nip05_server(); + server + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or("") + .to_string() + } + + /// Set the name-authority server (e.g. `https://other.example`). Pass an + /// empty string to reset to the default (goblin.st). + pub fn set_nip05_server(&mut self, server: Option) { + self.nip05_server = server.filter(|s| !s.trim().is_empty()); + self.save(); + } + + /// Seconds after which a still-pending transaction is auto-canceled/expired. + pub fn expiry_secs(&self) -> i64 { + self.expiry_secs.unwrap_or(24 * 60 * 60) + } + + /// Seconds before the manual cancel button appears on a pending send. + pub fn cancel_grace_secs(&self) -> i64 { + self.cancel_grace_secs.unwrap_or(600) + } + + pub fn allow_incoming_requests(&self) -> bool { + self.allow_incoming_requests.unwrap_or(true) + } + + pub fn set_allow_incoming_requests(&mut self, allow: bool) { + self.allow_incoming_requests = Some(allow); + self.save(); + } +} diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs new file mode 100644 index 00000000..299561e6 --- /dev/null +++ b/src/nostr/identity.rs @@ -0,0 +1,426 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Per-wallet nostr identity: NIP-06 derived from the wallet mnemonic by +//! default (one seed restores money AND identity) or imported from an nsec. +//! Stored at rest as NIP-49 ncryptsec encrypted with the wallet password. + +use nostr_sdk::nips::nip44; +use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity}; +use nostr_sdk::prelude::FromMnemonic; +use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32}; +use serde_derive::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +/// Where the keys came from. +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum IdentitySource { + /// NIP-06 derivation from the wallet BIP-39 mnemonic (legacy: binds the + /// identity to the seed forever). + Derived, + /// Imported nsec. + Imported, + /// Freshly generated random key, independent of the wallet seed: the + /// seed proves nothing about the identity and cannot resurrect it. + Random, +} + +/// Identity file stored at `wallet_data/nostr/identity.json`. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct NostrIdentity { + pub ver: u8, + pub source: IdentitySource, + /// NIP-06 account index used for derivation. + pub derivation_account: u32, + /// NIP-49 encrypted secret key (bech32 ncryptsec). + pub ncryptsec: String, + /// Public key, bech32 npub (plaintext so the UI can render pre-unlock). + pub npub: String, + /// Registered NIP-05 identifier (user@goblin.st). + pub nip05: Option, + /// User chose to stay anonymous (no NIP-05, no kind-0 metadata). + pub anonymous: bool, + /// Previous npubs from key rotations (newest last), for reference. + #[serde(default)] + pub prev_npubs: Vec, +} + +/// NIP-49 scrypt work factor (~64 MiB, interactive-grade). +const NCRYPTSEC_LOG_N: u8 = 16; + +/// Write a file with owner-only (0600) permissions on Unix. +fn write_private(path: &PathBuf, data: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(data)?; + // Also fix the mode if the file already existed with looser perms. + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + Ok(()) + } + #[cfg(not(unix))] + { + std::fs::write(path, data) + } +} + +/// Restrict a directory to owner-only access on Unix. +fn restrict_dir(dir: &PathBuf) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + #[cfg(not(unix))] + { + let _ = dir; + } +} + +#[derive(Debug, thiserror::Error)] +pub enum IdentityError { + #[error("identity io error: {0}")] + Io(#[from] std::io::Error), + #[error("identity parse error: {0}")] + Parse(#[from] serde_json::Error), + #[error("key error: {0}")] + Key(String), + #[error("wrong password")] + WrongPassword, +} + +impl NostrIdentity { + pub const FILE_NAME: &'static str = "identity.json"; + + /// Identity file path inside the wallet nostr directory. + pub fn path(nostr_dir: &PathBuf) -> PathBuf { + let mut path = nostr_dir.clone(); + path.push(Self::FILE_NAME); + path + } + + /// Load the identity file if it exists. + pub fn load(nostr_dir: &PathBuf) -> Option { + let path = Self::path(nostr_dir); + let raw = fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() + } + + /// Persist the identity file with owner-only permissions (the ncryptsec + /// blob must not be world-readable: a local attacker could grind the + /// wallet password offline otherwise). + pub fn save(&self, nostr_dir: &PathBuf) -> Result<(), IdentityError> { + fs::create_dir_all(nostr_dir)?; + restrict_dir(nostr_dir); + let raw = serde_json::to_string_pretty(self)?; + let path = Self::path(nostr_dir); + write_private(&path, raw.as_bytes())?; + Ok(()) + } + + /// Delete the identity file (used when the user discards the identity). + pub fn delete(nostr_dir: &PathBuf) { + let _ = fs::remove_file(Self::path(nostr_dir)); + } + + /// Derive keys from a BIP-39 mnemonic phrase via NIP-06. + pub fn derive_keys(mnemonic: &str, account: u32) -> Result { + Keys::from_mnemonic_with_account(mnemonic, None, Some(account)) + .map_err(|e| IdentityError::Key(format!("{e}"))) + } + + /// Create a derived identity from the wallet mnemonic, encrypting the + /// secret key with the wallet password. + pub fn create_derived( + mnemonic: &str, + password: &str, + account: u32, + ) -> Result<(NostrIdentity, Keys), IdentityError> { + let keys = Self::derive_keys(mnemonic, account)?; + let identity = Self::from_keys(&keys, password, IdentitySource::Derived, account)?; + Ok((identity, keys)) + } + + /// Build an identity from already-unlocked keys under a (possibly + /// different) password — used when importing a backup that was exported + /// under another wallet's password. + pub fn from_unlocked_keys( + keys: &Keys, + password: &str, + source: IdentitySource, + account: u32, + ) -> Result { + Self::from_keys(keys, password, source, account) + } + + /// Create a brand-new random identity, independent of the wallet seed. + pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> { + let keys = Keys::generate(); + let identity = Self::from_keys(&keys, password, IdentitySource::Random, 0)?; + Ok((identity, keys)) + } + + /// Create an imported identity from an nsec string. + pub fn create_imported( + nsec: &str, + password: &str, + ) -> Result<(NostrIdentity, Keys), IdentityError> { + let secret = SecretKey::parse(nsec.trim()) + .map_err(|e| IdentityError::Key(format!("invalid nsec: {e}")))?; + let keys = Keys::new(secret); + let identity = Self::from_keys(&keys, password, IdentitySource::Imported, 0)?; + Ok((identity, keys)) + } + + fn from_keys( + keys: &Keys, + password: &str, + source: IdentitySource, + account: u32, + ) -> Result { + let encrypted = EncryptedSecretKey::new( + keys.secret_key(), + password, + NCRYPTSEC_LOG_N, + KeySecurity::Medium, + ) + .map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?; + let ncryptsec = encrypted + .to_bech32() + .map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?; + let npub = keys + .public_key() + .to_bech32() + .map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?; + Ok(NostrIdentity { + ver: 1, + source, + derivation_account: account, + ncryptsec, + npub, + nip05: None, + anonymous: true, + prev_npubs: Vec::new(), + }) + } + + /// Decrypt the stored key with the wallet password. + pub fn unlock(&self, password: &str) -> Result { + let encrypted = EncryptedSecretKey::from_bech32(&self.ncryptsec) + .map_err(|e| IdentityError::Key(format!("invalid ncryptsec: {e}")))?; + let secret = encrypted + .decrypt(password) + .map_err(|_| IdentityError::WrongPassword)?; + Ok(Keys::new(secret)) + } + + /// Re-encrypt the stored key under a new password. + pub fn reencrypt(&mut self, old: &str, new: &str) -> Result<(), IdentityError> { + let keys = self.unlock(old)?; + let encrypted = + EncryptedSecretKey::new(keys.secret_key(), new, NCRYPTSEC_LOG_N, KeySecurity::Medium) + .map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?; + self.ncryptsec = encrypted + .to_bech32() + .map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?; + Ok(()) + } + + /// A single, fully-encrypted, portable backup of this identity (the contents + /// of a `GOBLIN-*.backup` file). Two sealed layers, no plaintext: the secret + /// key is the password-protected NIP-49 ncryptsec, and the rest of the + /// identity (username, history, source) is NIP-44-sealed to our own key. An + /// outside party sees only ciphertext — no npub, no name. Any Goblin wallet + /// reopens it with the backup's password. `keys` must be this identity's + /// unlocked keys (the caller unlocks with the password first). + pub fn to_encrypted_backup(&self, keys: &Keys) -> Result { + let json = serde_json::to_string(self)?; + let sealed = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + json, + nip44::Version::V2, + ) + .map_err(|e| IdentityError::Key(format!("seal failed: {e}")))?; + let envelope = serde_json::json!({ + "goblin_backup": 1, + "k": self.ncryptsec, + "d": sealed, + }); + serde_json::to_string(&envelope).map_err(IdentityError::from) + } + + /// True if `s` is a Goblin encrypted-backup envelope (vs a bare nsec or the + /// legacy plaintext identity JSON). + pub fn is_encrypted_backup(s: &str) -> bool { + serde_json::from_str::(s.trim()) + .ok() + .and_then(|v| v.get("goblin_backup").cloned()) + .is_some() + } + + /// Open an encrypted backup with its password, returning the embedded + /// identity and its unlocked keys. + pub fn from_encrypted_backup( + envelope: &str, + password: &str, + ) -> Result<(NostrIdentity, Keys), IdentityError> { + let v: serde_json::Value = serde_json::from_str(envelope.trim())?; + let k = v + .get("k") + .and_then(|x| x.as_str()) + .ok_or_else(|| IdentityError::Key("backup missing key".into()))?; + let d = v + .get("d") + .and_then(|x| x.as_str()) + .ok_or_else(|| IdentityError::Key("backup missing data".into()))?; + // Unlock the wrapper key with the password, then open the sealed JSON. + let enc = EncryptedSecretKey::from_bech32(k) + .map_err(|e| IdentityError::Key(format!("invalid backup: {e}")))?; + let secret = enc + .decrypt(password) + .map_err(|_| IdentityError::WrongPassword)?; + let keys = Keys::new(secret); + let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), d) + .map_err(|_| IdentityError::WrongPassword)?; + let identity: NostrIdentity = serde_json::from_str(&json)?; + Ok((identity, keys)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backup_restores_under_new_password() { + // Export under one wallet password, restore on a device with another. + let (a, _) = NostrIdentity::create_random("old-pw").unwrap(); + let json = serde_json::to_string(&a).unwrap(); + let parsed: NostrIdentity = serde_json::from_str(&json).unwrap(); + let keys = parsed.unlock("old-pw").unwrap(); + let b = NostrIdentity::from_unlocked_keys( + &keys, + "new-pw", + parsed.source, + parsed.derivation_account, + ) + .unwrap(); + assert_eq!(b.npub, a.npub); + assert!(b.unlock("new-pw").is_ok()); + assert!(b.unlock("old-pw").is_err()); + } + + #[test] + fn encrypted_backup_roundtrips_and_is_opaque() { + // A .backup file: sealed under one password, reopened with it. The + // envelope must carry no plaintext npub/name, and a wrong password fails. + let (mut a, keys) = NostrIdentity::create_random("pw-1").unwrap(); + a.nip05 = Some("jimbob@goblin.st".to_string()); + a.anonymous = false; + let envelope = a.to_encrypted_backup(&keys).unwrap(); + assert!(NostrIdentity::is_encrypted_backup(&envelope)); + // Opaque: neither the public key nor the username leaks in the file. + assert!(!envelope.contains(&a.npub)); + assert!(!envelope.contains("jimbob")); + // Reopen with the password → same identity. + let (restored, rkeys) = NostrIdentity::from_encrypted_backup(&envelope, "pw-1").unwrap(); + assert_eq!(restored.npub, a.npub); + assert_eq!(restored.nip05.as_deref(), Some("jimbob@goblin.st")); + assert_eq!(rkeys.public_key(), keys.public_key()); + // Wrong password can't open it. + assert!(NostrIdentity::from_encrypted_backup(&envelope, "wrong").is_err()); + } + + #[test] + fn random_identities_are_unlinked_and_unlock() { + let (a, ka) = NostrIdentity::create_random("pw-1").unwrap(); + let (b, _) = NostrIdentity::create_random("pw-1").unwrap(); + // Fresh entropy every time: no chain between identities. + assert_ne!(a.npub, b.npub); + assert_eq!(a.source, IdentitySource::Random); + // NIP-49 roundtrip with the right password; wrong one fails. + let unlocked = a.unlock("pw-1").unwrap(); + assert_eq!(unlocked.public_key(), ka.public_key()); + assert!(a.unlock("wrong").is_err()); + } + + // NIP-06 test vector: this mnemonic must derive this npub (account 0). + const NIP06_MNEMONIC: &str = + "leader monkey parrot ring guide accident before fence cannon height naive bean"; + const NIP06_NPUB: &str = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu"; + + #[test] + fn nip06_derivation_vector() { + let keys = NostrIdentity::derive_keys(NIP06_MNEMONIC, 0).unwrap(); + assert_eq!(keys.public_key().to_bech32().unwrap(), NIP06_NPUB); + } + + #[test] + fn encrypt_unlock_roundtrip() { + let (identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "hunter2", 0).unwrap(); + assert_eq!(identity.source, IdentitySource::Derived); + assert!(identity.anonymous); + let unlocked = identity.unlock("hunter2").unwrap(); + assert_eq!(unlocked.public_key(), keys.public_key()); + assert!(identity.unlock("wrong").is_err()); + } + + #[test] + fn import_nsec_roundtrip() { + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let (identity, imported) = NostrIdentity::create_imported(&nsec, "pw").unwrap(); + assert_eq!(identity.source, IdentitySource::Imported); + assert_eq!(imported.public_key(), keys.public_key()); + let unlocked = identity.unlock("pw").unwrap(); + assert_eq!(unlocked.public_key(), keys.public_key()); + } + + #[cfg(unix)] + #[test] + fn identity_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("goblin-id-test-{}", std::process::id())); + let (identity, _) = NostrIdentity::create_derived(NIP06_MNEMONIC, "pw", 0).unwrap(); + identity.save(&dir).unwrap(); + let meta = std::fs::metadata(NostrIdentity::path(&dir)).unwrap(); + // The ncryptsec blob must never be group/world readable. + assert_eq!( + meta.permissions().mode() & 0o077, + 0, + "identity.json must be 0600" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn reencrypt_changes_password() { + let (mut identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "old", 0).unwrap(); + identity.reencrypt("old", "new").unwrap(); + assert!(identity.unlock("old").is_err()); + assert_eq!( + identity.unlock("new").unwrap().public_key(), + keys.public_key() + ); + } +} diff --git a/src/nostr/ingest.rs b/src/nostr/ingest.rs new file mode 100644 index 00000000..13006699 --- /dev/null +++ b/src/nostr/ingest.rs @@ -0,0 +1,345 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! The guarded ingest policy: what to do with a validated incoming slate. +//! +//! Security invariants (do not weaken): +//! - Invoice1 (a request for US to PAY) is NEVER paid automatically. +//! - Standard2/Invoice2 replies only finalize when they match a pending +//! transaction we initiated AND the sender matches the stored counterparty. +//! - Everything else is dropped. + +use grin_wallet_libwallet::SlateState; + +use crate::nostr::config::AcceptPolicy; +use crate::nostr::types::{NostrSendStatus, NostrTxDirection, TxNostrMeta}; + +/// What the ingest pipeline should do with a validated slate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IngestDecision { + /// Standard1: receive the payment and reply S2 automatically. + AutoReceive, + /// Standard1 under a stricter accept policy: surface for approval. + SurfaceIncoming, + /// Standard2/Invoice2 reply matching our pending tx: finalize and post. + FinalizePost, + /// Invoice1: surface a payment request for explicit user approval. + SurfaceRequest, + /// Drop silently (reason for logging only). + Drop(&'static str), +} + +/// Inputs for the policy decision. +pub struct IngestContext<'a> { + /// Parsed slate state. + pub state: SlateState, + /// Parsed slate amount in atomic units. + pub amount: u64, + /// Seal-verified sender public key, hex. + pub sender: &'a str, + /// Stored nostr metadata for this slate id, when present. + pub meta: Option<&'a TxNostrMeta>, + /// Whether the wallet has a transaction with this slate id. + pub tx_exists: bool, + /// Whether the sender is a known (non-unknown) contact. + pub is_contact: bool, + /// Accept policy from wallet config. + pub accept: AcceptPolicy, + /// Whether incoming payment requests (Invoice1) are accepted (opt-out). + pub allow_requests: bool, +} + +/// Pure policy function — unit tested, no side effects. +pub fn decide(ctx: &IngestContext) -> IngestDecision { + match ctx.state { + SlateState::Standard1 => { + if ctx.amount == 0 { + return IngestDecision::Drop("zero amount"); + } + if ctx.tx_exists || ctx.meta.is_some() { + return IngestDecision::Drop("slate already known"); + } + match ctx.accept { + AcceptPolicy::Everyone => IngestDecision::AutoReceive, + AcceptPolicy::Contacts => { + if ctx.is_contact { + IngestDecision::AutoReceive + } else { + IngestDecision::SurfaceIncoming + } + } + AcceptPolicy::Ask => IngestDecision::SurfaceIncoming, + } + } + // Standard2 is the counterparty's reply to a send WE initiated. The + // status allow-set below includes Created/SendFailed (not just + // AwaitingS2) on purpose: our send records intent as Created BEFORE + // dispatch and only flips to AwaitingS2 after a relay accepts S1, so + // a crash in that gap leaves a legitimate pending send marked + // Created/SendFailed even though the counterparty did receive S1. + // This is NOT a forgery vector: a finalizing S2 must carry our S1 + // partial signature over our locked outputs, which the counterparty + // can only have if we actually sent it. Sender + tx_exists are still + // required, and grin rejects finalizing an already-finalized tx. + SlateState::Standard2 => match ctx.meta { + Some(meta) + if meta.direction == NostrTxDirection::Sent + && matches!( + meta.status, + NostrSendStatus::AwaitingS2 + | NostrSendStatus::Created + | NostrSendStatus::SendFailed + ) && meta.npub == ctx.sender + && ctx.tx_exists => + { + IngestDecision::FinalizePost + } + Some(meta) if meta.npub != ctx.sender => { + IngestDecision::Drop("S2 sender does not match stored counterparty") + } + _ => IngestDecision::Drop("S2 without matching pending send"), + }, + SlateState::Invoice1 => { + if ctx.amount == 0 { + return IngestDecision::Drop("zero amount"); + } + if ctx.tx_exists || ctx.meta.is_some() { + return IngestDecision::Drop("slate already known"); + } + // Honour the opt-out: when incoming requests are off, drop them. + // (Requesters also see this advertised in our profile beforehand.) + if !ctx.allow_requests { + return IngestDecision::Drop("incoming requests disabled"); + } + // NEVER pay automatically. + IngestDecision::SurfaceRequest + } + SlateState::Invoice2 => match ctx.meta { + Some(meta) + if meta.direction == NostrTxDirection::RequestedByUs + && matches!( + meta.status, + NostrSendStatus::AwaitingI2 + | NostrSendStatus::Created + | NostrSendStatus::SendFailed + ) && meta.npub == ctx.sender + && ctx.tx_exists => + { + IngestDecision::FinalizePost + } + Some(meta) if meta.npub != ctx.sender => { + IngestDecision::Drop("I2 sender does not match stored counterparty") + } + _ => IngestDecision::Drop("I2 without matching pending request"), + }, + _ => IngestDecision::Drop("unsupported slate state"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nostr::types::unix_time; + + const ALICE: &str = "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05"; + const MALLORY: &str = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + + fn meta(direction: NostrTxDirection, status: NostrSendStatus, npub: &str) -> TxNostrMeta { + TxNostrMeta { + ver: 1, + slate_id: "s".into(), + npub: npub.into(), + direction, + note: None, + status, + sent_event_id: None, + received_rumor_id: None, + created_at: unix_time(), + updated_at: unix_time(), + } + } + + fn ctx<'a>( + state: SlateState, + amount: u64, + sender: &'a str, + meta: Option<&'a TxNostrMeta>, + tx_exists: bool, + ) -> IngestContext<'a> { + IngestContext { + state, + amount, + sender, + meta, + tx_exists, + is_contact: false, + accept: AcceptPolicy::Everyone, + allow_requests: true, + } + } + + #[test] + fn s1_auto_receives_from_anyone_by_default() { + let c = ctx(SlateState::Standard1, 100, ALICE, None, false); + assert_eq!(decide(&c), IngestDecision::AutoReceive); + } + + #[test] + fn s1_zero_amount_drops() { + let c = ctx(SlateState::Standard1, 0, ALICE, None, false); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + + #[test] + fn s1_duplicate_drops() { + let m = meta( + NostrTxDirection::Received, + NostrSendStatus::RepliedS2, + ALICE, + ); + let c = ctx(SlateState::Standard1, 100, ALICE, Some(&m), false); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + let c2 = ctx(SlateState::Standard1, 100, ALICE, None, true); + assert!(matches!(decide(&c2), IngestDecision::Drop(_))); + } + + #[test] + fn s1_contacts_policy_surfaces_unknown() { + let mut c = ctx(SlateState::Standard1, 100, ALICE, None, false); + c.accept = AcceptPolicy::Contacts; + assert_eq!(decide(&c), IngestDecision::SurfaceIncoming); + c.is_contact = true; + assert_eq!(decide(&c), IngestDecision::AutoReceive); + } + + #[test] + fn s1_ask_policy_always_surfaces() { + let mut c = ctx(SlateState::Standard1, 100, ALICE, None, false); + c.accept = AcceptPolicy::Ask; + c.is_contact = true; + assert_eq!(decide(&c), IngestDecision::SurfaceIncoming); + } + + #[test] + fn s2_finalizes_only_matching_pending_send() { + let m = meta(NostrTxDirection::Sent, NostrSendStatus::AwaitingS2, ALICE); + let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true); + assert_eq!(decide(&c), IngestDecision::FinalizePost); + } + + #[test] + fn s2_from_wrong_sender_drops() { + let m = meta(NostrTxDirection::Sent, NostrSendStatus::AwaitingS2, ALICE); + let c = ctx(SlateState::Standard2, 100, MALLORY, Some(&m), true); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + + #[test] + fn s2_without_meta_drops() { + let c = ctx(SlateState::Standard2, 100, ALICE, None, true); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + + #[test] + fn s2_without_wallet_tx_drops() { + let m = meta(NostrTxDirection::Sent, NostrSendStatus::AwaitingS2, ALICE); + let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), false); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + + #[test] + fn s2_wrong_direction_drops() { + let m = meta( + NostrTxDirection::Received, + NostrSendStatus::RepliedS2, + ALICE, + ); + let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + + #[test] + fn s2_on_cancelled_send_drops() { + // Safety backstop for the cancel/reclaim race: once a manual "Cancel + // payment" (or 24h expiry) marks the meta Cancelled, a late S2 from a + // recipient who finally came online must be DROPPED — never re-finalized + // onto outputs the sender already reclaimed. + let m = meta(NostrTxDirection::Sent, NostrSendStatus::Cancelled, ALICE); + let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + + #[test] + fn s2_on_finalized_send_drops() { + // Idempotency: a duplicate S2 after we already finalized is dropped. + let m = meta(NostrTxDirection::Sent, NostrSendStatus::Finalized, ALICE); + let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + + #[test] + fn s2_finalizes_from_pre_dispatch_states() { + // Created/SendFailed are deliberately accepted: a crash between + // relay-accept and the AwaitingS2 write must not strand a real send. + for status in [NostrSendStatus::Created, NostrSendStatus::SendFailed] { + let m = meta(NostrTxDirection::Sent, status, ALICE); + let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true); + assert_eq!(decide(&c), IngestDecision::FinalizePost); + // Still bound to the counterparty: a stranger's S2 drops. + let c2 = ctx(SlateState::Standard2, 100, MALLORY, Some(&m), true); + assert!(matches!(decide(&c2), IngestDecision::Drop(_))); + } + } + + #[test] + fn i1_never_pays_automatically() { + // Even from a contact under the most permissive policy. + let mut c = ctx(SlateState::Invoice1, 100, ALICE, None, false); + c.is_contact = true; + c.accept = AcceptPolicy::Everyone; + assert_eq!(decide(&c), IngestDecision::SurfaceRequest); + } + + #[test] + fn i1_dropped_when_requests_disabled() { + let mut c = ctx(SlateState::Invoice1, 100, ALICE, None, false); + c.allow_requests = false; + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + + #[test] + fn i2_finalizes_only_matching_request() { + let m = meta( + NostrTxDirection::RequestedByUs, + NostrSendStatus::AwaitingI2, + ALICE, + ); + let c = ctx(SlateState::Invoice2, 100, ALICE, Some(&m), true); + assert_eq!(decide(&c), IngestDecision::FinalizePost); + let c2 = ctx(SlateState::Invoice2, 100, MALLORY, Some(&m), true); + assert!(matches!(decide(&c2), IngestDecision::Drop(_))); + } + + #[test] + fn terminal_states_drop() { + for state in [ + SlateState::Standard3, + SlateState::Invoice3, + SlateState::Unknown, + ] { + let c = ctx(state, 100, ALICE, None, false); + assert!(matches!(decide(&c), IngestDecision::Drop(_))); + } + } +} diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs new file mode 100644 index 00000000..663c311d --- /dev/null +++ b/src/nostr/mod.rs @@ -0,0 +1,43 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Nostr payment-messaging subsystem: contacts are nostr users, slatepacks +//! travel as NIP-17 private DMs (NIP-44 encrypted, NIP-59 gift-wrapped) over +//! relays reached through the in-process Nym mixnet client. + +mod types; +pub use types::*; + +pub mod config; +pub use config::{AcceptPolicy, NostrConfig}; + +pub mod relays; + +mod store; +pub use store::NostrStore; + +mod identity; +pub use identity::{IdentitySource, NostrIdentity}; + +pub mod protocol; +pub use protocol::*; + +pub mod ingest; +pub use ingest::*; + +mod client; +pub use client::{NostrProfile, NostrService, send_phase}; + +pub mod avatar; +pub mod nip05; diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs new file mode 100644 index 00000000..47a55cba --- /dev/null +++ b/src/nostr/nip05.rs @@ -0,0 +1,490 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! NIP-05 username resolution/verification and goblin.st registration, +//! all HTTP routed through the Nym mixnet (the local SOCKS5 proxy). Nothing +//! here touches clearnet. + +use base64::Engine; +use nostr_sdk::{EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::nostr::relays::HOME_NIP05_DOMAIN; +use crate::nym; +use parking_lot::RwLock; + +/// The active name-authority "home" domain, mirrored here from the wallet config +/// once per frame so resolution + display (some on worker threads) can read it +/// without threading the config through every call site. `None` = the default +/// (goblin.st). Federation: set this to another authority and bare names resolve +/// there and own-domain names display without a domain suffix. +static HOME_DOMAIN: RwLock> = RwLock::new(None); + +/// Mirror the configured name authority's host (e.g. `goblin.st`). Empty resets +/// to the default. +pub fn set_home_domain(domain: &str) { + *HOME_DOMAIN.write() = if domain.trim().is_empty() { + None + } else { + Some(domain.trim().to_lowercase()) + }; +} + +/// The current name-authority home domain (configured or the goblin.st default). +pub fn home_domain() -> String { + HOME_DOMAIN + .read() + .clone() + .unwrap_or_else(|| HOME_NIP05_DOMAIN.to_string()) +} + +/// Result of resolving a NIP-05 identifier. +#[derive(Debug, Clone)] +pub struct Nip05Resolution { + pub pubkey: PublicKey, + pub relays: Vec, +} + +/// Parse `user@domain` into (name, domain). A bare `@user` or `user` +/// resolves against the home domain (goblin.st). +pub fn split_identifier(input: &str) -> Option<(String, String)> { + let trimmed = input.trim().trim_start_matches('@'); + if trimmed.is_empty() { + return None; + } + match trimmed.split_once('@') { + Some((name, domain)) if !name.is_empty() => { + let domain = domain.to_lowercase(); + if !is_valid_hostname(&domain) { + return None; + } + Some((name.to_lowercase(), domain)) + } + Some(_) => None, + None => Some((trimmed.to_lowercase(), home_domain())), + } +} + +/// A bare DNS hostname: dotted ASCII labels only — no path, query, port, +/// userinfo or whitespace. Stops a `user@domain` from smuggling an +/// attacker-chosen path/host into the `/.well-known/nostr.json` URL. +fn is_valid_hostname(d: &str) -> bool { + if d.len() > 253 || !d.contains('.') || d.contains("..") { + return false; + } + d.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) +} + +/// Resolve a NIP-05 identifier (user@domain) to a pubkey + relay hints. +pub async fn resolve(name: &str, domain: &str) -> Option { + let url = format!( + "https://{}/.well-known/nostr.json?name={}", + domain, + urlencode(name) + ); + let body = nym::http_request("GET", url, None, vec![]).await?; + parse_well_known(&body, name) +} + +/// Reverse lookup against an authority: the active `@name` a pubkey holds, if +/// any. Authoritative and single-request — unlike fetching the peer's kind-0 off +/// a relay and verifying the NIP-05 it advertises, this needs no profile fetch, +/// so a contact's name resolves even when their profile can't be retrieved. +/// `Some(name)` = server-confirmed; `None` = the key has no name on this +/// authority OR the server was unreachable (the two are indistinguishable here, +/// so callers must NOT treat `None` as "released" — fall back to the kind-0 + +/// verify path, which can tell a definitive miss from a network blip). +pub async fn name_by_pubkey(domain: &str, pubkey_hex: &str) -> Option { + let url = format!( + "https://{}/api/v1/by-pubkey/{}", + domain, + urlencode(pubkey_hex) + ); + let body = nym::http_request("GET", url, None, vec![]).await?; + let doc: Value = serde_json::from_str(&body).ok()?; + doc.get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +/// Verify that a pubkey matches its claimed NIP-05 identifier. +pub async fn verify(pubkey: &PublicKey, name: &str, domain: &str) -> bool { + match resolve(name, domain).await { + Some(res) => res.pubkey == *pubkey, + None => false, + } +} + +/// Outcome of re-checking whether a name still belongs to a key — distinguishes +/// a definitive "no longer ours" from a transient network failure, so a cached +/// name is only cleared when the server actually says it's gone/reassigned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Nip05Check { + /// Server reachable; the name maps to this key. + Verified, + /// Server reachable and answered, but the name is absent or maps to a + /// DIFFERENT key (released, or reassigned to someone else). + Mismatch, + /// Couldn't reach/parse the server — unknown; keep what we have. + Unreachable, +} + +/// Freshness-aware NIP-05 check (see [`Nip05Check`]). Only returns `Mismatch` +/// when the server gives a well-formed answer that doesn't include this key — +/// any network error or non-well-known response is `Unreachable`. +pub async fn check(pubkey: &PublicKey, name: &str, domain: &str) -> Nip05Check { + let url = format!( + "https://{}/.well-known/nostr.json?name={}", + domain, + urlencode(name) + ); + let Some(body) = nym::http_request("GET", url, None, vec![]).await else { + return Nip05Check::Unreachable; + }; + check_body(&body, pubkey, name) +} + +/// Decide a [`Nip05Check`] from a fetched well-known body (split out for tests). +fn check_body(body: &str, pubkey: &PublicKey, name: &str) -> Nip05Check { + // A reachable server that returns non-JSON, or a doc without a `names` map, + // is treated as Unreachable — never clear a good name on a server hiccup. + let Ok(doc) = serde_json::from_str::(body) else { + return Nip05Check::Unreachable; + }; + let Some(names) = doc.get("names").and_then(|n| n.as_object()) else { + return Nip05Check::Unreachable; + }; + match names.get(name).and_then(|v| v.as_str()) { + Some(hex) if PublicKey::from_hex(hex).ok().as_ref() == Some(pubkey) => Nip05Check::Verified, + // Name absent, or present but a different key → definitively not ours. + _ => Nip05Check::Mismatch, + } +} + +/// Parse a .well-known/nostr.json document for a specific name. +pub fn parse_well_known(body: &str, name: &str) -> Option { + let doc: Value = serde_json::from_str(body).ok()?; + let pk_hex = doc.get("names")?.get(name)?.as_str()?; + let pubkey = PublicKey::from_hex(pk_hex).ok()?; + let relays = doc + .get("relays") + .and_then(|r| r.get(pk_hex)) + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + Some(Nip05Resolution { pubkey, relays }) +} + +/// Availability result from the registration server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Availability { + Available, + Taken, + Reserved, + Invalid, + Quarantined, + Unknown, +} + +/// Check name availability against the identity server. +pub async fn check_availability(server: &str, name: &str) -> Availability { + let url = format!( + "{}/api/v1/name/{}", + server.trim_end_matches('/'), + urlencode(name) + ); + let body = match nym::http_request("GET", url, None, vec![]).await { + Some(b) => b, + None => return Availability::Unknown, + }; + let Ok(doc) = serde_json::from_str::(&body) else { + return Availability::Unknown; + }; + if doc.get("available").and_then(|v| v.as_bool()) == Some(true) { + return Availability::Available; + } + match doc.get("reason").and_then(|v| v.as_str()) { + Some("taken") => Availability::Taken, + Some("reserved") => Availability::Reserved, + Some("invalid") => Availability::Invalid, + Some("quarantined") => Availability::Quarantined, + _ => Availability::Unknown, + } +} + +/// Build a NIP-98 Authorization header value for a request. +fn nip98_auth(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> Option { + let mut tags = vec![ + Tag::custom(TagKind::custom("u"), [url.to_string()]), + Tag::custom(TagKind::custom("method"), [method.to_string()]), + ]; + if let Some(body) = body { + let hash = hex::encode(Sha256::digest(body)); + tags.push(Tag::custom(TagKind::custom("payload"), [hash])); + } + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .ok()?; + let encoded = base64::engine::general_purpose::STANDARD.encode(event.as_json()); + Some(format!("Nostr {}", encoded)) +} + +/// Registration outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegisterResult { + /// Registered (or already owned): full nip05 identifier. + Ok(String), + /// Name conflict (taken/quarantined/pubkey already has a name). + Conflict(String), + /// Request rejected (invalid/reserved/unauthorized). + Rejected(String), + /// Network failure. + Network, +} + +/// Register `name` for our keys at the identity server (NIP-98 authed). +pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/register", server); + let body = serde_json::json!({ + "name": name.to_lowercase(), + "pubkey": keys.public_key().to_hex(), + }) + .to_string(); + let Some(auth) = nip98_auth(keys, &url, "POST", Some(body.as_bytes())) else { + return RegisterResult::Rejected("auth event build failed".into()); + }; + let headers = vec![ + ("Authorization".to_string(), auth), + ("Content-Type".to_string(), "application/json".to_string()), + ]; + let Some(resp) = nym::http_request("POST", url, Some(body), headers).await else { + return RegisterResult::Network; + }; + let Ok(doc) = serde_json::from_str::(&resp) else { + return RegisterResult::Rejected(format!("bad response: {}", resp)); + }; + if let Some(nip05) = doc.get("nip05").and_then(|v| v.as_str()) { + return RegisterResult::Ok(nip05.to_string()); + } + let err = doc + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error") + .to_string(); + if err.contains("taken") || err.contains("quarantined") || err.contains("already has") { + RegisterResult::Conflict(err) + } else { + RegisterResult::Rejected(err) + } +} + +/// Release a registered name (NIP-98 authed by the owner). +pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), String> { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/register/{}", server, urlencode(name)); + let Some(auth) = nip98_auth(keys, &url, "DELETE", None) else { + return Err("couldn't sign the request".to_string()); + }; + let headers = vec![("Authorization".to_string(), auth)]; + match nym::http_request("DELETE", url, None, headers).await { + Some(resp) if resp.contains("\"released\":true") => Ok(()), + Some(resp) => Err(serde_json::from_str::(&resp) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| "server refused the release".to_string())), + None => Err("network unreachable".to_string()), + } +} + +/// Upload a processed avatar PNG for an owned name. Returns the content +/// hash on success. NIP-98 payload hashing makes the request replay-proof. +pub async fn upload_avatar( + server: &str, + name: &str, + keys: &Keys, + png: Vec, +) -> Result { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/avatar/{}", server, urlencode(name)); + let Some(auth) = nip98_auth(keys, &url, "POST", Some(&png)) else { + return Err("couldn't sign the request".to_string()); + }; + let headers = vec![ + ("Authorization".to_string(), auth), + ( + "Content-Type".to_string(), + "application/octet-stream".to_string(), + ), + ]; + match nym::http_request_bytes("POST", url, Some(png), headers).await { + Some((201, raw)) => serde_json::from_slice::(&raw) + .ok() + .and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from)) + .ok_or_else(|| "unexpected server response".to_string()), + Some((429, _)) => Err("Avatar limit reached — try again tomorrow".to_string()), + Some((413, _)) => Err("Image too large".to_string()), + Some((422, _)) => Err("That file doesn't look like a usable image".to_string()), + Some((code, raw)) => Err(serde_json::from_slice::(&raw) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| format!("server error ({code})"))), + None => Err("network unreachable".to_string()), + } +} + +/// Remove the avatar for an owned name. +pub async fn delete_avatar(server: &str, name: &str, keys: &Keys) -> Result<(), String> { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/avatar/{}", server, urlencode(name)); + let Some(auth) = nip98_auth(keys, &url, "DELETE", None) else { + return Err("couldn't sign the request".to_string()); + }; + let headers = vec![("Authorization".to_string(), auth)]; + match nym::http_request_bytes("DELETE", url, None, headers).await { + Some((200, _)) => Ok(()), + Some((code, _)) => Err(format!("server error ({code})")), + None => Err("network unreachable".to_string()), + } +} + +/// Public profile probe: `None` = network failure, `Some(None)` = name has +/// no avatar (or no such name), `Some(Some(hash))` = avatar content hash. +pub async fn fetch_profile(server: &str, name: &str) -> Option> { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/profile/{}", server, urlencode(name)); + let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?; + if code == 404 { + return Some(None); + } + if code != 200 { + return None; + } + let v: serde_json::Value = serde_json::from_slice(&raw).ok()?; + Some(v.get("avatar").and_then(|h| h.as_str()).map(String::from)) +} + +/// Download a processed avatar by content hash. Verifies size and PNG +/// magic before returning — a misbehaving server can't feed the UI junk. +pub async fn fetch_avatar(server: &str, hash: &str) -> Option> { + if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/avatar/{}.png", server, hash); + let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?; + if code != 200 || raw.len() > 1_048_576 || !raw.starts_with(&[0x89, b'P', b'N', b'G']) { + return None; + } + Some(raw) +} + +/// Minimal percent-encoding for name path/query segments. +fn urlencode(s: &str) -> String { + s.chars() + .flat_map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + vec![c] + } else { + format!("%{:02X}", c as u32).chars().collect() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_identifiers() { + assert_eq!( + split_identifier("@ada"), + Some(("ada".to_string(), "goblin.st".to_string())) + ); + assert_eq!( + split_identifier("ada"), + Some(("ada".to_string(), "goblin.st".to_string())) + ); + assert_eq!( + split_identifier("Ada@Example.COM"), + Some(("ada".to_string(), "example.com".to_string())) + ); + assert_eq!(split_identifier("ada@"), None); + assert_eq!(split_identifier(""), None); + // Reject anything that isn't a bare hostname (SSRF / path smuggling). + assert_eq!(split_identifier("a@evil.tld/.well-known/x?u="), None); + assert_eq!(split_identifier("a@1.2.3.4:8080"), None); + assert_eq!(split_identifier("a@nodot"), None); + } + + #[test] + fn parses_well_known() { + let body = r#"{ + "names": {"ada": "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05"}, + "relays": {"91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05": ["wss://relay.goblin.st"]} + }"#; + let res = parse_well_known(body, "ada").unwrap(); + assert_eq!(res.relays, vec!["wss://relay.goblin.st".to_string()]); + assert!(parse_well_known(body, "bob").is_none()); + assert!(parse_well_known("not json", "ada").is_none()); + } + + #[test] + fn check_body_classifies() { + let ada_hex = "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05"; + let ada = PublicKey::from_hex(ada_hex).unwrap(); + let other = + PublicKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + let body = format!(r#"{{"names":{{"ada":"{ada_hex}"}},"relays":{{}}}}"#); + + // Name maps to this key → Verified. + assert_eq!(check_body(&body, &ada, "ada"), Nip05Check::Verified); + // Name present but a DIFFERENT key (reassigned) → Mismatch. + assert_eq!(check_body(&body, &other, "ada"), Nip05Check::Mismatch); + // Name absent from a valid doc (released) → Mismatch. + assert_eq!(check_body(&body, &ada, "bob"), Nip05Check::Mismatch); + // Empty names map (the exact "released" shape) → Mismatch. + assert_eq!( + check_body(r#"{"names":{},"relays":{}}"#, &ada, "testuser"), + Nip05Check::Mismatch + ); + // Non-JSON / server error → Unreachable (never clears a good name). + assert_eq!( + check_body("503 Service Unavailable", &ada, "ada"), + Nip05Check::Unreachable + ); + // Valid JSON but no `names` map (unexpected response) → Unreachable. + assert_eq!( + check_body(r#"{"error":"oops"}"#, &ada, "ada"), + Nip05Check::Unreachable + ); + } +} diff --git a/src/nostr/protocol.rs b/src/nostr/protocol.rs new file mode 100644 index 00000000..e9525b65 --- /dev/null +++ b/src/nostr/protocol.rs @@ -0,0 +1,260 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Goblin payment message protocol over NIP-17 (kind 14 rumors). +//! +//! Content layout: a one-line human readable preamble, a blank line and the +//! raw slatepack armor. The per-payment note travels in the standard +//! `subject` tag; a `goblin` tag marks the protocol version. Classification +//! NEVER trusts tags — only the parsed slate. + +use nostr_sdk::{Tag, TagKind, Tags}; +use regex::Regex; +use std::sync::LazyLock; + +/// Maximum gift wrap content size accepted before unwrapping. +pub const MAX_WRAP_CONTENT: usize = 64 * 1024; +/// Maximum rumor content size accepted after unwrapping. +pub const MAX_RUMOR_CONTENT: usize = 32 * 1024; +/// Maximum slatepack armor size accepted. +pub const MAX_SLATEPACK: usize = 30 * 1024; +/// Maximum note length in characters after sanitization. +pub const MAX_NOTE_CHARS: usize = 256; +/// Protocol marker tag name. +pub const GOBLIN_TAG: &str = "goblin"; +/// Protocol version value. +pub const PROTOCOL_VERSION: &str = "1"; +/// Control-message tag name: carries `[action, slate_id]` for a request that is +/// being voided (a decline by the payer or a cancel by the requester). +pub const GOBLIN_ACTION_TAG: &str = "goblin-action"; +/// The one control action: void an unpaid request. Decline and cancel are the +/// same wire message — "this request is off" — they only differ by who sends it. +pub const ACTION_VOID: &str = "void"; + +/// Human readable preamble other NIP-17 clients render. +pub const PREAMBLE: &str = + "[Goblin] GRIN payment message — open in Goblin (https://goblin.st) to process."; + +static SLATEPACK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"BEGINSLATEPACK\.[\s\S]*?ENDSLATEPACK\.").expect("slatepack regex") +}); + +/// Sanitize a user note: strip control characters, collapse whitespace, +/// trim and cap the length. Returns `None` when nothing readable remains. +pub fn sanitize_note(raw: &str) -> Option { + let cleaned: String = raw + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let collapsed = cleaned.split_whitespace().collect::>().join(" "); + let trimmed = collapsed.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.chars().take(MAX_NOTE_CHARS).collect()) +} + +/// Build the kind-14 rumor content for a slatepack payment message. +pub fn build_payment_content(slatepack: &str) -> String { + format!("{}\n\n{}", PREAMBLE, slatepack.trim()) +} + +/// Build rumor tags: protocol marker plus optional subject note. +pub fn build_rumor_tags(note: Option<&str>) -> Vec { + let mut tags = vec![Tag::custom( + TagKind::custom(GOBLIN_TAG), + [PROTOCOL_VERSION.to_string()], + )]; + if let Some(note) = note.and_then(sanitize_note) { + tags.push(Tag::custom(TagKind::custom("subject"), [note])); + } + tags +} + +/// Build the kind-14 rumor content for a request-void control message. Carries +/// NO slatepack — other NIP-17 clients render the human-readable line. +pub fn build_control_content() -> String { + "[Goblin] Payment request withdrawn — open in Goblin (https://goblin.st).".to_string() +} + +/// Build rumor tags for a control message: protocol marker plus the action + +/// slate id the control refers to. +pub fn build_control_tags(slate_id: &str) -> Vec { + vec![ + Tag::custom(TagKind::custom(GOBLIN_TAG), [PROTOCOL_VERSION.to_string()]), + Tag::custom( + TagKind::custom(GOBLIN_ACTION_TAG), + [ACTION_VOID.to_string(), slate_id.to_string()], + ), + ] +} + +/// Read a control action from rumor tags. Returns the referenced slate id when a +/// well-formed `goblin-action` void tag is present, else `None`. Classification +/// NEVER trusts this for payment processing — it only voids a pending request, +/// and the caller still binds the sender to the stored counterparty. +pub fn extract_control(tags: &Tags) -> Option { + for tag in tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some(GOBLIN_ACTION_TAG) { + let action = parts.get(1).map(|s| s.as_str()); + let slate_id = parts.get(2).map(|s| s.to_string()); + if action == Some(ACTION_VOID) { + if let Some(id) = slate_id.filter(|s| !s.is_empty()) { + return Some(id); + } + } + } + } + None +} + +/// Extract exactly one slatepack armor block from rumor content. +/// More than one block, none at all, or an oversized block returns `None`. +pub fn extract_slatepack(content: &str) -> Option { + if content.len() > MAX_RUMOR_CONTENT { + return None; + } + let mut matches = SLATEPACK_RE.find_iter(content); + let first = matches.next()?; + if matches.next().is_some() { + // Multiple blocks: ambiguous, refuse. + return None; + } + let armor = first.as_str().trim().to_string(); + if armor.len() > MAX_SLATEPACK { + return None; + } + Some(armor) +} + +/// Read the sanitized subject (note) from rumor tags. +pub fn extract_subject(tags: &Tags) -> Option { + for tag in tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("subject") { + if let Some(value) = parts.get(1) { + return sanitize_note(value); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const PACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \ + pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \ + dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K \ + bRGBoQbtTNCb12u DBMTNGsCT7iqGd3 7Sya3iCMu9PdcKW QzL3Wh4qsuTRMyL \ + R3Atup1Bf3wgEbi ENMmTon9zFMD3fE 2muWLSZJYnSbN16 89zvvW45w3sQekX \ + 7d6FGCdJqDXfsmt Gh3CSNNRz7emxZw uHEDFmYqgUkSCk2 ZXAeFCSWZ3nogyB \ + o9LL75ZAYTbAQ3d e1bQAGmiKWWQAJ8 oCWk5NHnf6QJhLB ZAtNYUiBu6dgNRM \ + ZqxYBhWHtcSkpFn PmJh1nLDfyTbAmM 1AQpoxFBRMUyDmf nNZ75bL5xX9KQVB \ + C1q4HEgqgRtAvNo 1deUSPYsCfRZ1Wd k2Lqo6w8oCe2cyU rMcLnRYrFgL27dT \ + gZBYLgAfqqHRWaR cnNnnXMNpdNuQbe ojMNMTBuFFHJSus PCBVcvHGEKnYHWS \ + W3PCH1MFowyfDxX 4D3DcsGnSAEAxFt 9rEzNuKbcKEfL9z gKVQoCKqzUXVNCZ \ + jaG7M8B7etApvXr i1qzezfk7rTQz1k 6XJDjFb1JoTL5wo bSdkzfXJDBfWtAB \ + gVMVkSdSXgcZqWS XL4MwBR8VfPv78s g7eRJVuRrBaQTKn xGRT7keqLBPMRRA \ + LXkPDgQpHWpFei4 fnUVcuV4EWXarmm 3a1tBZpAvgTKuvF mvVAyeJTagrEXrS \ + J2scK99rjQuLpAZ 1135LqkGfMQRmkN 4cWEoYzM3U6BS2y mD3sCctEMNHJKKa \ + amGfXo16VLEjvw1 LvAVGFqyo64UQHV V63ufGc3qZkZcSU 1bSaCSDsKs8jzkz \ + 6jztk3DqqUiZBV3 reNzHKAEhMCfWtD W9STzaTwiakwwGq mcsHcUVJ9SVi7Hd \ + 1cKB9PNJ6FRJUjh AHWoaXBHRRGCNcm fpPMA9Hxn3BNXgs 8gDosk8mTpnDFRA \ + uYbA8eX4d2BG2Hd YsApEnjGBkXuXdg eEdyDvfqQEUDRRG iAjp6X5ZQ6JCNYP \ + LFNAFwkjqQ8XqRs aXmDgYTV4hpVtuc 5w69tnULM7vEnXm 14tHK9GktqgNBVy \ + LJiVf8feoFc1Lao MEXVJSdpu7sUSn2 8Mz9zPS7XJWyAyT 36WuJSx7DjMpnB2 \ + 2vqXAjMwYAXmL2V Vmm2Y8wmhomBd1A YwPmTKAm5gFBL5W RkAGUJxq46DCWbz \ + mzaBhLqswMGcRUf qmiPiQGqGEMnyQy yMa2HSc9wbXc78d 8GCkRgYepCFK7tC \ + Ynw5HuANFLBJgXM zYbR6XLkP8cSC7. ENDSLATEPACK."; + + #[test] + fn extracts_single_slatepack() { + let content = format!("{}\n\n{}", PREAMBLE, PACK); + let got = extract_slatepack(&content).unwrap(); + assert!(got.starts_with("BEGINSLATEPACK.")); + assert!(got.ends_with("ENDSLATEPACK.")); + } + + #[test] + fn rejects_no_slatepack() { + assert!(extract_slatepack("hi there, no payment here").is_none()); + assert!(extract_slatepack("").is_none()); + assert!(extract_slatepack("BEGINSLATEPACK. truncated junk").is_none()); + } + + #[test] + fn rejects_two_slatepacks() { + let content = format!("{} {}", PACK, PACK); + assert!(extract_slatepack(&content).is_none()); + } + + #[test] + fn rejects_oversize() { + let huge = format!( + "BEGINSLATEPACK. {} ENDSLATEPACK.", + "A".repeat(MAX_SLATEPACK + 1) + ); + assert!(extract_slatepack(&huge).is_none()); + let oversize_content = "x".repeat(MAX_RUMOR_CONTENT + 1); + assert!(extract_slatepack(&oversize_content).is_none()); + } + + #[test] + fn sanitizes_notes() { + assert_eq!(sanitize_note(" lunch :) "), Some("lunch :)".to_string())); + assert_eq!( + sanitize_note("a\u{0000}b\u{001b}[31mc"), + Some("a b [31mc".to_string()) + ); + assert_eq!( + sanitize_note("multi space\n\nnewline"), + Some("multi space newline".to_string()) + ); + assert_eq!(sanitize_note("\u{0007}\u{0008}"), None); + assert_eq!(sanitize_note(""), None); + let long = "y".repeat(MAX_NOTE_CHARS + 50); + assert_eq!( + sanitize_note(&long).unwrap().chars().count(), + MAX_NOTE_CHARS + ); + } + + #[test] + fn builds_content_with_preamble() { + let c = build_payment_content(PACK); + assert!(c.starts_with(PREAMBLE)); + assert!(extract_slatepack(&c).is_some()); + } + + #[test] + fn control_round_trips_slate_id() { + let tags = Tags::from_list(build_control_tags("abc-123")); + assert_eq!(extract_control(&tags), Some("abc-123".to_string())); + // A control message carries no slatepack. + assert!(extract_slatepack(&build_control_content()).is_none()); + } + + #[test] + fn control_absent_or_malformed_returns_none() { + // Ordinary payment tags have no action tag. + let tags = Tags::from_list(build_rumor_tags(Some("lunch"))); + assert!(extract_control(&tags).is_none()); + // Action present but empty slate id is rejected. + let bad = Tags::from_list(build_control_tags("")); + assert!(extract_control(&bad).is_none()); + } +} diff --git a/src/nostr/relays.rs b/src/nostr/relays.rs new file mode 100644 index 00000000..ad55e712 --- /dev/null +++ b/src/nostr/relays.rs @@ -0,0 +1,72 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Default relay set and relay list helpers. + +/// Default DM relays: the Goblin relay plus large public relays for redundancy. +pub const DEFAULT_RELAYS: &[&str] = &[ + "wss://relay.goblin.st", + "wss://relay.damus.io", + "wss://nos.lol", +]; + +/// Default NIP-05 identity server. +pub const DEFAULT_NIP05_SERVER: &str = "https://goblin.st"; + +/// Domain whose NIP-05 names display as plain @user. +pub const HOME_NIP05_DOMAIN: &str = "goblin.st"; + +/// Maximum relays published in the kind 10050 DM relay list (NIP-17 guidance). +pub const MAX_DM_RELAYS: usize = 3; + +/// Normalize a user-entered relay url (adds wss:// when missing). +pub fn normalize_relay_url(input: &str) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + let url = if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") { + trimmed.to_string() + } else { + format!("wss://{}", trimmed) + }; + // Basic shape validation. + match nostr_sdk::Url::parse(&url) { + Ok(u) if u.host_str().is_some() => Some(url), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_relay_urls() { + assert_eq!( + normalize_relay_url("relay.goblin.st"), + Some("wss://relay.goblin.st".to_string()) + ); + assert_eq!( + normalize_relay_url("wss://relay.damus.io/"), + Some("wss://relay.damus.io".to_string()) + ); + assert_eq!( + normalize_relay_url("ws://127.0.0.1:8088"), + Some("ws://127.0.0.1:8088".to_string()) + ); + assert_eq!(normalize_relay_url(""), None); + assert_eq!(normalize_relay_url(" "), None); + } +} diff --git a/src/nostr/store.rs b/src/nostr/store.rs new file mode 100644 index 00000000..53dcc554 --- /dev/null +++ b/src/nostr/store.rs @@ -0,0 +1,322 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Per-wallet nostr metadata archive: tx metadata, contacts, payment requests +//! and processed-event markers. rkv (SafeMode) storage under the wallet data +//! directory — the user-controlled local archive. + +use rkv::backend::{SafeMode, SafeModeDatabase, SafeModeEnvironment}; +use rkv::{Manager, Rkv, SingleStore, StoreOptions, Value}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use crate::nostr::types::*; + +/// Keys are processed-event markers older than this get pruned (30 days). +const PROCESSED_TTL_SECS: i64 = 30 * 86_400; + +/// Nostr metadata archive for a wallet. +pub struct NostrStore { + env: Arc>>, + /// Tx metadata by slate uuid. + tx_meta: SingleStore, + /// Contacts by pubkey hex. + contacts: SingleStore, + /// Payment requests by rumor id hex. + requests: SingleStore, + /// Processed markers (event/rumor ids and slate states) to timestamps. + processed: SingleStore, + /// Service settings (last connected time etc). + settings: SingleStore, +} + +impl NostrStore { + /// Open or create the archive in the provided directory. + pub fn new(dir: PathBuf) -> Self { + let _ = fs::create_dir_all(&dir); + let mut manager = Manager::::singleton().write().unwrap(); + // Open with headroom above the 5 stores below: rkv's SafeMode checks + // capacity before existence, so reopening an env that already holds + // `DEFAULT_MAX_DBS` (5) named dbs fails with DbsFull. + let created_arc = manager + .get_or_create(dir.as_path(), |p: &std::path::Path| { + Rkv::with_capacity::(p, 16) + }) + .unwrap(); + let env = created_arc.clone(); + let k = created_arc.read().unwrap(); + + let tx_meta = k + .open_single("nostr_tx_meta", StoreOptions::create()) + .unwrap(); + let contacts = k + .open_single("nostr_contacts", StoreOptions::create()) + .unwrap(); + let requests = k + .open_single("nostr_requests", StoreOptions::create()) + .unwrap(); + let processed = k + .open_single("nostr_processed", StoreOptions::create()) + .unwrap(); + let settings = k + .open_single("nostr_settings", StoreOptions::create()) + .unwrap(); + Self { + env, + tx_meta, + contacts, + requests, + processed, + settings, + } + } + + fn get_json( + &self, + store: &SingleStore, + key: &str, + ) -> Option { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let reader = env.read().unwrap(); + if let Ok(Some(Value::Json(raw))) = store.get(&reader, key) { + return serde_json::from_str(raw).ok(); + } + None + } + + fn put_json(&self, store: &SingleStore, key: &str, value: &T) { + if let Ok(raw) = serde_json::to_string(value) { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let mut writer = env.write().unwrap(); + let _ = store.put(&mut writer, key, &Value::Json(&raw)); + let _ = writer.commit(); + } + } + + fn delete(&self, store: &SingleStore, key: &str) { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let mut writer = env.write().unwrap(); + let _ = store.delete(&mut writer, key); + let _ = writer.commit(); + } + + fn all_json(&self, store: &SingleStore) -> Vec { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let reader = env.read().unwrap(); + let mut out = vec![]; + if let Ok(iter) = store.iter_start(&reader) { + for item in iter.flatten() { + if let (_, Value::Json(raw)) = item { + if let Ok(v) = serde_json::from_str(raw) { + out.push(v); + } + } + } + } + out + } + + fn clear(&self, store: &SingleStore) { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let mut writer = env.write().unwrap(); + let _ = store.clear(&mut writer); + let _ = writer.commit(); + } + + // ── tx metadata ───────────────────────────────────────────────────────── + + pub fn tx_meta(&self, slate_id: &str) -> Option { + self.get_json(&self.tx_meta, slate_id) + } + + pub fn save_tx_meta(&self, meta: &TxNostrMeta) { + self.put_json(&self.tx_meta, &meta.slate_id, meta); + } + + pub fn all_tx_meta(&self) -> Vec { + self.all_json(&self.tx_meta) + } + + /// Update status of existing tx metadata. + pub fn update_tx_status(&self, slate_id: &str, status: NostrSendStatus) { + if let Some(mut meta) = self.tx_meta(slate_id) { + meta.status = status; + meta.updated_at = unix_time(); + self.save_tx_meta(&meta); + } + } + + // ── contacts ──────────────────────────────────────────────────────────── + + pub fn contact(&self, npub_hex: &str) -> Option { + self.get_json(&self.contacts, npub_hex) + } + + pub fn save_contact(&self, contact: &Contact) { + self.put_json(&self.contacts, &contact.npub, contact); + } + + pub fn delete_contact(&self, npub_hex: &str) { + self.delete(&self.contacts, npub_hex); + } + + pub fn all_contacts(&self) -> Vec { + self.all_json(&self.contacts) + } + + // ── payment requests ──────────────────────────────────────────────────── + + pub fn request(&self, rumor_id: &str) -> Option { + self.get_json(&self.requests, rumor_id) + } + + pub fn save_request(&self, request: &PaymentRequest) { + self.put_json(&self.requests, &request.rumor_id, request); + } + + pub fn all_requests(&self) -> Vec { + self.all_json(&self.requests) + } + + /// Update the status of an existing payment request. + pub fn update_request_status(&self, rumor_id: &str, status: RequestStatus) { + if let Some(mut req) = self.request(rumor_id) { + req.status = status; + self.save_request(&req); + } + } + + pub fn pending_requests(&self) -> Vec { + let mut reqs: Vec = self + .all_requests() + .into_iter() + .filter(|r| r.status == RequestStatus::Pending) + .collect(); + reqs.sort_by_key(|r| std::cmp::Reverse(r.received_at)); + reqs + } + + // ── processed markers ─────────────────────────────────────────────────── + + pub fn is_processed(&self, key: &str) -> bool { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let reader = env.read().unwrap(); + matches!(self.processed.get(&reader, key), Ok(Some(_))) + } + + pub fn mark_processed(&self, key: &str) { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let mut writer = env.write().unwrap(); + let _ = self + .processed + .put(&mut writer, key, &Value::I64(unix_time())); + let _ = writer.commit(); + } + + /// Remove processed markers older than the TTL. + pub fn prune_processed(&self) { + let cutoff = unix_time() - PROCESSED_TTL_SECS; + let stale: Vec = { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let reader = env.read().unwrap(); + let mut stale = vec![]; + if let Ok(iter) = self.processed.iter_start(&reader) { + for item in iter.flatten() { + if let (key, Value::I64(ts)) = item { + if ts < cutoff { + if let Ok(k) = std::str::from_utf8(key) { + stale.push(k.to_string()); + } + } + } + } + } + stale + }; + for key in stale { + self.delete(&self.processed, &key); + } + } + + // ── settings ──────────────────────────────────────────────────────────── + + pub fn last_connected_at(&self) -> Option { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let reader = env.read().unwrap(); + if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, "last_connected_at") { + return Some(v); + } + None + } + + pub fn set_last_connected_at(&self, ts: i64) { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let mut writer = env.write().unwrap(); + let _ = self + .settings + .put(&mut writer, "last_connected_at", &Value::I64(ts)); + let _ = writer.commit(); + } + + /// Unix time of the last contact-name re-verify sweep (persisted across + /// restarts so a fresh launch only re-sweeps if it's been a while). + pub fn last_name_sweep_at(&self) -> Option { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let reader = env.read().unwrap(); + if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, "last_name_sweep_at") { + return Some(v); + } + None + } + + pub fn set_last_name_sweep_at(&self, ts: i64) { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let mut writer = env.write().unwrap(); + let _ = self + .settings + .put(&mut writer, "last_name_sweep_at", &Value::I64(ts)); + let _ = writer.commit(); + } + + // ── archive control (user-facing) ─────────────────────────────────────── + + /// Export the whole archive as a JSON document. + pub fn export_json(&self, npub: &str) -> String { + let doc = serde_json::json!({ + "exported_at": unix_time(), + "npub": npub, + "contacts": self.all_contacts(), + "tx_meta": self.all_tx_meta(), + "requests": self.all_requests(), + }); + serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string()) + } + + /// Wipe payment history metadata (keeps contacts). + pub fn wipe_archive(&self) { + self.clear(&self.tx_meta); + self.clear(&self.requests); + self.clear(&self.processed); + } + + /// Wipe everything including contacts. + pub fn wipe_all(&self) { + self.wipe_archive(); + self.clear(&self.contacts); + self.clear(&self.settings); + } +} diff --git a/src/nostr/types.rs b/src/nostr/types.rs new file mode 100644 index 00000000..39e0b881 --- /dev/null +++ b/src/nostr/types.rs @@ -0,0 +1,150 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Shared types of the nostr payment-messaging subsystem. + +use serde_derive::{Deserialize, Serialize}; + +/// Direction of a nostr-transported transaction relative to this wallet. +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum NostrTxDirection { + /// We sent funds (Standard flow, we created S1). + Sent, + /// We received funds (Standard flow, we replied S2). + Received, + /// We issued an invoice / requested funds (Invoice flow, we created I1). + RequestedByUs, + /// Someone requested funds of us (Invoice flow, we may pay I1). + RequestedOfUs, +} + +/// Lifecycle status of a nostr-transported transaction. +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum NostrSendStatus { + /// Slate created locally, DM not dispatched yet. + Created, + /// S1 DM dispatched, waiting for the S2 reply. + AwaitingS2, + /// Incoming S1 processed, S2 reply not yet dispatched (crash recovery). + ReceivedNoReply, + /// S2 reply dispatched for a received payment. + RepliedS2, + /// I1 request dispatched, waiting for the I2 reply. + AwaitingI2, + /// We paid an invoice (I2 reply sent), their side finalizes. + PaidAwaitingFinalize, + /// Transaction finalized and posted. + Finalized, + /// DM dispatch failed, retry possible. + SendFailed, + /// Cancelled locally. + Cancelled, +} + +/// Outcome of a manual payment-cancel, surfaced transiently on the receipt so +/// the user knows whether their funds came back or the payment had already +/// completed in the race window. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CancelOutcome { + /// The pending payment was cancelled and the locked funds released. + Cancelled, + /// The payment had already gone through; nothing was cancelled. + AlreadyCompleted, +} + +/// Per-transaction nostr metadata, joined to wallet txs by slate id. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TxNostrMeta { + pub ver: u8, + /// Slate UUID string. + pub slate_id: String, + /// Counterparty public key, hex. + pub npub: String, + pub direction: NostrTxDirection, + /// Sanitized user note (subject line). + pub note: Option, + pub status: NostrSendStatus, + /// Gift wrap event id of our outgoing message, hex. + pub sent_event_id: Option, + /// Rumor id of the counterparty message we processed, hex. + pub received_rumor_id: Option, + pub created_at: i64, + pub updated_at: i64, +} + +/// A contact: another nostr user we can pay. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Contact { + pub ver: u8, + /// Public key, hex. + pub npub: String, + /// Local petname, overrides any resolved name. + pub petname: Option, + /// NIP-05 identifier (user@domain). + pub nip05: Option, + /// Unix time of last successful NIP-05 verification. + pub nip05_verified_at: Option, + /// Known DM relays (kind 10050) of the contact. + pub relays: Vec, + /// Avatar palette index. + pub hue: u8, + /// Auto-added from an incoming payment, not yet confirmed by the user. + pub unknown: bool, + pub added_at: i64, + pub last_paid_at: Option, + /// Blocked at the nostr level: their incoming messages are dropped on + /// ingest, as if muted on nostr (which is what this is). + #[serde(default)] + pub blocked: bool, +} + +/// Status of an incoming payment request (Invoice1). +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum RequestStatus { + Pending, + Approved, + Declined, + Expired, + /// Withdrawn by the requester (we received their cancel control message). + Cancelled, +} + +/// An incoming Invoice1 payment request awaiting explicit user approval. +/// NEVER paid automatically. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PaymentRequest { + pub ver: u8, + /// Rumor event id, hex (storage key). + pub rumor_id: String, + /// Slate UUID string. + pub slate_id: String, + /// Raw slatepack armor to pay on approval. + pub slatepack: String, + /// Requester public key, hex. + pub npub: String, + /// Requested amount in atomic units. + pub amount: u64, + /// Sanitized note. + pub note: Option, + pub received_at: i64, + pub status: RequestStatus, +} + +/// Current unix time in seconds. +pub fn unix_time() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} diff --git a/src/nym/mod.rs b/src/nym/mod.rs new file mode 100644 index 00000000..2476c709 --- /dev/null +++ b/src/nym/mod.rs @@ -0,0 +1,87 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and +//! every HTTP request (NIP-05, price, avatars) — is routed through Goblin's +//! in-process Nym SOCKS5 client (the Nym SDK linked directly, no subprocess) +//! that tunnels over the 5-hop mixnet to a network requester. The mixnet breaks +//! the sender↔receiver timing correlation that Mimblewimble's interactive slate +//! exchange otherwise leaks at the network layer, and it bootstraps in ~2s. +//! Nothing goes clearnet. + +pub mod sidecar; +pub mod transport; + +use std::time::Duration; + +pub use sidecar::{is_ready, warm_up}; +pub use transport::NymWebSocketTransport; + +/// Local SOCKS5 endpoint exposed by the in-process Nym SOCKS5 client. +/// `socks5h` keeps DNS resolution inside the proxy so the destination host is +/// never resolved on the clear. +pub const SOCKS5_HOST: &str = "127.0.0.1"; +pub const SOCKS5_PORT: u16 = 1080; + +/// `socks5h://127.0.0.1:1080` proxy URL for reqwest. +pub fn proxy_url() -> String { + format!("socks5h://{SOCKS5_HOST}:{SOCKS5_PORT}") +} + +/// `127.0.0.1:1080` for the raw SOCKS5 TCP dialer (relay websockets). +pub fn socks5_addr() -> String { + format!("{SOCKS5_HOST}:{SOCKS5_PORT}") +} + +/// An HTTP request routed over the Nym mixnet via the in-process SOCKS5 client. +/// Returns `(status, body)`. +pub async fn http_request_bytes( + method: &str, + url: String, + body: Option>, + headers: Vec<(String, String)>, +) -> Option<(u16, Vec)> { + let proxy = reqwest::Proxy::all(proxy_url()).ok()?; + let client = reqwest::Client::builder() + .proxy(proxy) + .user_agent("goblin-wallet") + // The mixnet adds deliberate per-hop delay; allow generous time. + .timeout(Duration::from_secs(60)) + .build() + .ok()?; + let m = reqwest::Method::from_bytes(method.as_bytes()).ok()?; + let mut req = client.request(m, &url); + for (k, v) in headers { + req = req.header(k, v); + } + if let Some(b) = body { + req = req.body(b); + } + let resp = req.send().await.ok()?; + let code = resp.status().as_u16(); + let bytes = resp.bytes().await.ok()?.to_vec(); + Some((code, bytes)) +} + +/// String-bodied convenience wrapper around [`http_request_bytes`]. +pub async fn http_request( + method: &str, + url: String, + body: Option, + headers: Vec<(String, String)>, +) -> Option { + http_request_bytes(method, url, body.map(|b| b.into_bytes()), headers) + .await + .map(|(_, raw)| String::from_utf8_lossy(&raw).to_string()) +} diff --git a/src/nym/sidecar.rs b/src/nym/sidecar.rs new file mode 100644 index 00000000..af36de33 --- /dev/null +++ b/src/nym/sidecar.rs @@ -0,0 +1,154 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! In-process Nym mixnet client. Goblin links the Nym SDK directly — there is no +//! sidecar subprocess and no bundled/sideloaded binary. It runs the SDK's SOCKS5 +//! client on a private internal tokio runtime, exposing the mixnet at +//! `127.0.0.1:1080`; every relay + HTTP request in the app is pointed at that +//! loopback port, so all traffic egresses through the 5-hop mixnet to our network +//! requester. Nothing goes clearnet. + +use std::net::{SocketAddr, TcpStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use log::{error, info, warn}; + +use nym_sdk::mixnet::{MixnetClientBuilder, Socks5, Socks5MixnetClient, StoragePaths}; + +use super::{SOCKS5_HOST, SOCKS5_PORT}; + +/// Network requester (the mixnet exit) Goblin routes through — the SOCKS5 +/// client's `--provider`. Standard Nym exit policy, which permits the wss/443 + +/// HTTPS hosts Goblin needs. Overridable at runtime with `GOBLIN_NYM_PROVIDER`. If +/// left empty, the in-process client isn't started, but an already-running SOCKS5 +/// endpoint on :1080 is still reused. +pub const NETWORK_REQUESTER: &str = "5ibBQ9SS1er3tks5tfmrzCQ29qU1uBSvZN2dUwLKPRwu.HdbktiMVniUyaKBnorFVXLRHdwRb8iG9dV481r5xyopV@2RmEBKhQHsqvw5sjnnt2Bhpy96MPDUkbfWkT6r2RWNCR"; + +/// Pre-warm the mixnet transport in the background so relays / NIP-05 / price are +/// ready by first use. If a SOCKS5 endpoint is already listening on :1080 it is +/// reused as-is; otherwise the in-process client is started. +pub fn warm_up() { + thread::spawn(|| { + if port_open(Duration::from_millis(300)) { + info!("nym: reusing SOCKS5 endpoint already listening on {SOCKS5_HOST}:{SOCKS5_PORT}"); + MIXNET_READY.store(true, Ordering::Relaxed); + return; + } + run_client(); + }); +} + +/// Set once the local mixnet SOCKS5 proxy (:1080) is up and accepting. +static MIXNET_READY: AtomicBool = AtomicBool::new(false); + +/// Whether the mixnet proxy is warm. Cheap and cached — safe to poll from the UI +/// each frame, unlike a fresh TCP probe. Distinct from a relay being connected. +pub fn is_ready() -> bool { + MIXNET_READY.load(Ordering::Relaxed) +} + +/// True when something accepts TCP on the SOCKS5 port. +fn port_open(timeout: Duration) -> bool { + let addr: SocketAddr = match format!("{SOCKS5_HOST}:{SOCKS5_PORT}").parse() { + Ok(a) => a, + Err(_) => return false, + }; + TcpStream::connect_timeout(&addr, timeout).is_ok() +} + +/// The network requester address to register against (`--provider`). +fn provider() -> String { + std::env::var("GOBLIN_NYM_PROVIDER") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| NETWORK_REQUESTER.to_string()) +} + +/// Persistent storage dir for the in-process client's identity + gateway choice, +/// so the gateway is selected once and reused across launches (cuts cold-start +/// time). `/.goblin/nym`. `None` ⇒ fall back to ephemeral in-memory keys. +fn storage_dir() -> Option { + dirs::home_dir().map(|h| h.join(".goblin").join("nym")) +} + +/// Build the in-process SOCKS5 mixnet client on a dedicated multi-thread tokio +/// runtime, then keep the client (its SOCKS5 listener + mixnet tasks) AND the +/// runtime alive for the lifetime of the process. Blocks the calling thread. +fn run_client() { + let prov = provider(); + if prov.is_empty() { + warn!( + "nym: no network requester configured (set GOBLIN_NYM_PROVIDER or bake NETWORK_REQUESTER); mixnet disabled" + ); + return; + } + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + error!("nym: could not build mixnet runtime: {e}"); + return; + } + }; + rt.block_on(async move { + let started = Instant::now(); + info!("nym: starting in-process SOCKS5 mixnet client on {SOCKS5_HOST}:{SOCKS5_PORT}"); + let client = match build_client(prov).await { + Ok(c) => c, + Err(e) => { + error!("nym: mixnet client failed to start: {e}"); + return; + } + }; + info!( + "nym: mixnet ready on {SOCKS5_HOST}:{SOCKS5_PORT} in ~{}ms (nym addr {})", + started.elapsed().as_millis(), + client.nym_address() + ); + MIXNET_READY.store(true, Ordering::Relaxed); + // Hold the client (and thus the SOCKS5 listener + mixnet tasks) open for + // the whole process lifetime; the runtime keeps polling them. + std::future::pending::<()>().await; + drop(client); + }); +} + +/// Persistent identity if we have a home dir, else ephemeral in-memory keys. +async fn build_client(provider: String) -> Result { + match storage_dir() { + Some(dir) => { + let _ = std::fs::create_dir_all(&dir); + let paths = StoragePaths::new_from_dir(&dir)?; + MixnetClientBuilder::new_with_default_storage(paths) + .await? + .socks5_config(Socks5::new(provider)) + .build()? + .connect_to_mixnet_via_socks5() + .await + } + None => { + MixnetClientBuilder::new_ephemeral() + .socks5_config(Socks5::new(provider)) + .build()? + .connect_to_mixnet_via_socks5() + .await + } + } +} diff --git a/src/nym/transport.rs b/src/nym/transport.rs new file mode 100644 index 00000000..30558d92 --- /dev/null +++ b/src/nym/transport.rs @@ -0,0 +1,156 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! WebSocket transport for the Nostr relay pool routed through Goblin's +//! in-process Nym SOCKS5 client, so every relay connection traverses the 5-hop +//! Nym mixnet. We open a SOCKS5 connection to `127.0.0.1:1080`, ask the proxy +//! to reach the relay host (`socks5h`-style: the proxy does the DNS, so the +//! destination is never resolved on the clear), then run the TLS + websocket +//! handshake over that tunnel. Nothing goes clearnet. + +use std::fmt; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use async_wsocket::futures_util::{Sink, SinkExt, StreamExt}; +use async_wsocket::{ConnectionMode, Message}; +use nostr_relay_pool::transport::error::TransportError; +use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport}; +use nostr_sdk::Url; +use nostr_sdk::util::BoxedFuture; +use tokio_socks::tcp::Socks5Stream; +use tokio_tungstenite::tungstenite::Message as TgMessage; + +/// Error type for transport failures outside the websocket layer. +#[derive(Debug)] +struct NymTransportError(String); + +impl fmt::Display for NymTransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for NymTransportError {} + +fn terr(msg: impl Into) -> TransportError { + TransportError::backend(NymTransportError(msg.into())) +} + +/// Nostr websocket transport over the local Nym SOCKS5 proxy. +#[derive(Debug, Clone, Copy, Default)] +pub struct NymWebSocketTransport; + +impl WebSocketTransport for NymWebSocketTransport { + fn support_ping(&self) -> bool { + true + } + + fn connect<'a>( + &'a self, + url: &'a Url, + _mode: &'a ConnectionMode, + timeout: Duration, + ) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> { + Box::pin(async move { + let host = url + .host_str() + .ok_or_else(|| terr("relay url has no host"))? + .to_string(); + let port = url.port().unwrap_or(match url.scheme() { + "ws" => 80, + _ => 443, + }); + + // Dial the relay host through the local Nym SOCKS5 client. The proxy + // resolves the host inside the mixnet, so no clearnet DNS leak. + let stream = tokio::time::timeout( + timeout, + Socks5Stream::connect(crate::nym::socks5_addr().as_str(), (host.as_str(), port)), + ) + .await + .map_err(|_| terr("nym socks5 connect timeout"))? + .map_err(|e| terr(format!("nym socks5 connect failed: {e}")))?; + + // Perform TLS (for wss) + websocket handshake over the mixnet stream. + let (ws, _response) = tokio::time::timeout( + timeout, + tokio_tungstenite::client_async_tls(url.as_str(), stream), + ) + .await + .map_err(|_| terr("websocket handshake timeout"))? + .map_err(|e| terr(format!("websocket handshake failed: {e}")))?; + + let (tx, rx) = ws.split(); + + let sink: WebSocketSink = Box::new(NymSink(tx)) as WebSocketSink; + let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move { + match msg { + Ok(tg) => tg_to_message(tg).map(Ok), + Err(e) => Some(Err(TransportError::backend(e))), + } + })) as WebSocketStream; + + Ok((sink, stream)) + }) + } +} + +/// Convert a tungstenite message into an async-wsocket pool message. +/// Returns `None` for raw frames (never surfaced while reading). +fn tg_to_message(msg: TgMessage) -> Option { + match msg { + TgMessage::Text(text) => Some(Message::Text(text.to_string())), + TgMessage::Binary(data) => Some(Message::Binary(data.to_vec())), + TgMessage::Ping(data) => Some(Message::Ping(data.to_vec())), + TgMessage::Pong(data) => Some(Message::Pong(data.to_vec())), + TgMessage::Close(_) => Some(Message::Close(None)), + TgMessage::Frame(_) => None, + } +} + +/// Sink adapter converting pool messages into tungstenite messages. +struct NymSink(S); + +impl Sink for NymSink +where + S: Sink + Send + Unpin, +{ + type Error = TransportError; + + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0) + .poll_ready_unpin(cx) + .map_err(TransportError::backend) + } + + fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + Pin::new(&mut self.0) + .start_send_unpin(TgMessage::from(item)) + .map_err(TransportError::backend) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0) + .poll_flush_unpin(cx) + .map_err(TransportError::backend) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0) + .poll_close_unpin(cx) + .map_err(TransportError::backend) + } +} diff --git a/src/settings/config.rs b/src/settings/config.rs new file mode 100644 index 00000000..d0d2909d --- /dev/null +++ b/src/settings/config.rs @@ -0,0 +1,591 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use grin_core::global; +use grin_core::global::ChainTypes; +use serde_derive::{Deserialize, Serialize}; + +use crate::Settings; +use crate::gui::views::Content; +use crate::http::ReleaseInfo; +use crate::node::NodeConfig; +use crate::wallet::ConnectionsConfig; + +/// Application update information. +#[derive(Serialize, Deserialize, Clone)] +pub struct AppUpdate { + /// Version of release. + pub version: String, + /// Size of release in megabytes. + pub size: Option, + /// Date of release. + pub date: String, + /// Changes in the release. + pub changelog: String, + /// Link to download the release. + pub url: String, +} + +/// Application configuration, stored at toml file. +#[derive(Serialize, Deserialize)] +pub struct AppConfig { + /// Run node server on startup. + pub(crate) auto_start_node: bool, + /// Chain type for node and wallets. + pub(crate) chain_type: ChainTypes, + + /// Flag to check if Android integrated node warning was shown. + android_integrated_node_warning: Option, + + /// Flag to show wallet list at dual panel wallets mode. + show_wallets_at_dual_panel: bool, + /// Flag to show all connections at network panel or integrated node info. + show_connections_network_panel: bool, + + /// Width of the desktop window. + width: f32, + /// Height of the desktop window. + height: f32, + + /// Position of the desktop window. + x: Option, + y: Option, + + /// Locale code for i18n. + lang: Option, + /// Flag to use English locale layout on keyboard. + english_keyboard: Option, + + /// Flag to check if dark theme should be used, use system settings if not set. + use_dark_theme: Option, + /// Color theme identifier: "light", "dark" or "yellow". + theme: Option, + /// Density identifier: "compact", "regular" or "comfy". + density: Option, + /// Identifier of the last opened wallet to boot into. + last_wallet_id: Option, + /// Show fiat (USD) preview alongside amounts (legacy; migrated to pairing). + fiat_preview: Option, + /// Amount pairing code: off|usd|eur|gbp|jpy|cny|btc|sats (default usd). + pairing: Option, + + /// Flag to use proxy for network requests. + use_proxy: Option, + /// Flag to use SOCKS5 or HTTP proxy for network requests. + use_socks_proxy: Option, + /// HTTP proxy URL. + http_proxy_url: Option, + /// SOCKS5 proxy URL. + socks_proxy_url: Option, + + /// Flag to check updates on startup. + check_updates: Option, + /// Application update information. + app_update: Option, +} + +/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Pairing { + Off, + Usd, + Eur, + Gbp, + Jpy, + Cny, + Btc, + Sats, +} + +impl Pairing { + /// All variants, in picker order. + pub const ALL: [Pairing; 8] = [ + Pairing::Off, + Pairing::Usd, + Pairing::Eur, + Pairing::Gbp, + Pairing::Jpy, + Pairing::Cny, + Pairing::Btc, + Pairing::Sats, + ]; + + /// Stable config code. + pub fn code(&self) -> &'static str { + match self { + Pairing::Off => "off", + Pairing::Usd => "usd", + Pairing::Eur => "eur", + Pairing::Gbp => "gbp", + Pairing::Jpy => "jpy", + Pairing::Cny => "cny", + Pairing::Btc => "btc", + Pairing::Sats => "sats", + } + } + + pub fn from_code(s: &str) -> Option { + Some(match s { + "off" => Pairing::Off, + "usd" => Pairing::Usd, + "eur" => Pairing::Eur, + "gbp" => Pairing::Gbp, + "jpy" => Pairing::Jpy, + "cny" => Pairing::Cny, + "btc" => Pairing::Btc, + "sats" => Pairing::Sats, + _ => return None, + }) + } + + /// The CoinGecko `vs_currency` to price against (sats prices vs btc). + /// `None` when pairing is off. + pub fn vs_currency(&self) -> Option<&'static str> { + match self { + Pairing::Off => None, + Pairing::Sats => Some("btc"), + other => Some(other.code()), + } + } + + /// Human label for the picker / settings row. + pub fn label(&self) -> &'static str { + match self { + Pairing::Off => "Off", + Pairing::Usd => "USD", + Pairing::Eur => "EUR", + Pairing::Gbp => "GBP", + Pairing::Jpy => "JPY", + Pairing::Cny => "CNY", + Pairing::Btc => "Bitcoin", + Pairing::Sats => "Sats", + } + } +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + auto_start_node: false, + chain_type: ChainTypes::default(), + android_integrated_node_warning: None, + show_wallets_at_dual_panel: false, + show_connections_network_panel: false, + width: Self::DEFAULT_WIDTH, + height: Self::DEFAULT_HEIGHT, + x: None, + y: None, + lang: None, + english_keyboard: None, + use_dark_theme: None, + theme: None, + density: None, + last_wallet_id: None, + fiat_preview: None, + pairing: None, + use_proxy: None, + use_socks_proxy: None, + http_proxy_url: None, + socks_proxy_url: None, + // On by default, like upstream Grim: checks Goblin's own GitHub + // releases direct over HTTPS (see http/release.rs). This is the same + // non-sensitive-metadata-over-clearnet posture Grim uses for its + // update check — payments, relays and identity still stay mixnet-only. + check_updates: Some(true), + app_update: None, + } + } +} + +impl AppConfig { + /// Desktop window frame margin sum, horizontal or vertical. + const FRAME_MARGIN: f32 = Content::WINDOW_FRAME_MARGIN * 2.0; + /// Default desktop window width. + pub const DEFAULT_WIDTH: f32 = Content::SIDE_PANEL_WIDTH * 3.0 + Self::FRAME_MARGIN; + /// Default desktop window height. + pub const DEFAULT_HEIGHT: f32 = 706.0; + /// Minimal desktop window width. + pub const MIN_WIDTH: f32 = Content::SIDE_PANEL_WIDTH + Self::FRAME_MARGIN; + /// Minimal desktop window height. + pub const MIN_HEIGHT: f32 = 630.0 + Content::WINDOW_TITLE_HEIGHT + Self::FRAME_MARGIN; + + /// Application configuration file name. + pub const FILE_NAME: &'static str = "app.toml"; + + /// Default i18n locale. + pub const DEFAULT_LOCALE: &'static str = "en"; + + /// Save application configuration to the file. + pub fn save(&self) { + Settings::write_to_file(self, Settings::config_path(Self::FILE_NAME, None)); + } + + /// Change global [`ChainTypes`] and load new [`NodeConfig`]. + pub fn change_chain_type(chain_type: &ChainTypes) { + let current_chain_type = Self::chain_type(); + if current_chain_type != *chain_type { + // Save chain type at app config. + { + let mut w_app_config = Settings::app_config_to_update(); + w_app_config.chain_type = *chain_type; + w_app_config.save(); + } + // Load node configuration for selected chain type. + { + let mut w_node_config = Settings::node_config_to_update(); + let node_config = NodeConfig::for_chain_type(chain_type); + w_node_config.node = node_config.node; + w_node_config.peers = node_config.peers; + } + // Load connections configuration + { + let mut w_conn_config = Settings::conn_config_to_update(); + *w_conn_config = ConnectionsConfig::for_chain_type(chain_type); + } + } + if !global::GLOBAL_CHAIN_TYPE.is_init() { + global::init_global_chain_type(*chain_type); + } else { + global::set_global_chain_type(*chain_type); + global::set_local_chain_type(*chain_type); + } + } + + /// Get current [`ChainTypes`] for node and wallets. + pub fn chain_type() -> ChainTypes { + let r_config = Settings::app_config_to_read(); + r_config.chain_type + } + + /// Check if integrated node is starting with application. + pub fn autostart_node() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.auto_start_node + } + + /// Toggle integrated node autostart. + pub fn toggle_node_autostart() { + let autostart = Self::autostart_node(); + let mut w_app_config = Settings::app_config_to_update(); + w_app_config.auto_start_node = !autostart; + w_app_config.save(); + } + + /// Check if it's needed to show wallet list at dual panel wallets mode. + pub fn show_wallets_at_dual_panel() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.show_wallets_at_dual_panel + } + + /// Toggle flag to show wallet list at dual panel wallets mode. + pub fn toggle_show_wallets_at_dual_panel() { + let show = Self::show_wallets_at_dual_panel(); + let mut w_app_config = Settings::app_config_to_update(); + w_app_config.show_wallets_at_dual_panel = !show; + w_app_config.save(); + } + + /// Check if it's needed to show all connections or integrated node info at network panel. + pub fn show_connections_network_panel() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.show_connections_network_panel + } + + /// Toggle flag to show all connections or integrated node info at network panel. + pub fn toggle_show_connections_network_panel() { + let show = Self::show_connections_network_panel(); + let mut w_app_config = Settings::app_config_to_update(); + w_app_config.show_connections_network_panel = !show; + w_app_config.save(); + } + + /// Save desktop window width and height. + pub fn save_window_size(w: f32, h: f32) { + let mut w_app_config = Settings::app_config_to_update(); + w_app_config.width = w; + w_app_config.height = h; + w_app_config.save(); + } + + /// Get desktop window width and height. + pub fn window_size() -> (f32, f32) { + let r_config = Settings::app_config_to_read(); + (r_config.width, r_config.height) + } + + /// Save desktop window position. + pub fn save_window_pos(x: f32, y: f32) { + let mut w_app_config = Settings::app_config_to_update(); + w_app_config.x = Some(x); + w_app_config.y = Some(y); + w_app_config.save(); + } + + /// Get desktop window position. + pub fn window_pos() -> Option<(f32, f32)> { + let r_config = Settings::app_config_to_read(); + if r_config.x.is_some() && r_config.y.is_some() { + return Some((r_config.x.unwrap(), r_config.y.unwrap())); + } + None + } + + /// Save locale code. + pub fn save_locale(lang: &str) { + let mut w_app_config = Settings::app_config_to_update(); + w_app_config.lang = Some(lang.to_string()); + w_app_config.save(); + } + + /// Get current saved locale code. + pub fn locale() -> Option { + let r_config = Settings::app_config_to_read(); + if r_config.lang.is_some() { + return Some(r_config.lang.clone().unwrap()); + } + None + } + + /// Toggle English locale layout. for software keyboard. + pub fn toggle_english_keyboard() { + let english = Self::english_keyboard(); + let mut w_app_config = Settings::app_config_to_update(); + w_app_config.english_keyboard = Some(!english); + w_app_config.save(); + } + + /// Check if English locale layout should be used for software keyboard. + pub fn english_keyboard() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.english_keyboard.unwrap_or(false) + } + + /// Check if integrated node warning is needed for Android. + pub fn android_integrated_node_warning_needed() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.android_integrated_node_warning.unwrap_or(true) + } + + /// Mark integrated node warning for Android as shown. + pub fn show_android_integrated_node_warning() { + let mut w_config = Settings::app_config_to_update(); + w_config.android_integrated_node_warning = Some(false); + w_config.save(); + } + + /// Check if dark theme should be used (derived from the theme tokens). + pub fn dark_theme() -> Option { + let r_config = Settings::app_config_to_read(); + if let Some(theme) = r_config.theme.clone() { + if let Some(kind) = crate::gui::theme::ThemeKind::from_id(&theme) { + return Some(match kind { + crate::gui::theme::ThemeKind::Light => false, + crate::gui::theme::ThemeKind::Dark => true, + // Yellow paints dark ink on a light background. + crate::gui::theme::ThemeKind::Yellow => false, + }); + } + } + r_config.use_dark_theme.clone() + } + + /// Setup flag to use dark theme (legacy path, maps to theme identifier). + pub fn set_dark_theme(use_dark: bool) { + Self::set_theme(if use_dark { + crate::gui::theme::ThemeKind::Dark + } else { + crate::gui::theme::ThemeKind::Light + }); + } + + /// Get current color theme, migrating the legacy dark flag when present. + pub fn theme() -> crate::gui::theme::ThemeKind { + let r_config = Settings::app_config_to_read(); + if let Some(theme) = r_config.theme.clone() { + if let Some(kind) = crate::gui::theme::ThemeKind::from_id(&theme) { + return kind; + } + } + match r_config.use_dark_theme { + Some(false) => crate::gui::theme::ThemeKind::Light, + // Goblin defaults to the dark theme. + _ => crate::gui::theme::ThemeKind::Dark, + } + } + + /// Save color theme. + pub fn set_theme(kind: crate::gui::theme::ThemeKind) { + let mut w_config = Settings::app_config_to_update(); + w_config.theme = Some(kind.id().to_string()); + w_config.use_dark_theme = Some(kind == crate::gui::theme::ThemeKind::Dark); + w_config.save(); + } + + /// Get current density. + pub fn density() -> crate::gui::theme::DensityKind { + let r_config = Settings::app_config_to_read(); + r_config + .density + .clone() + .and_then(|d| crate::gui::theme::DensityKind::from_id(&d)) + .unwrap_or(crate::gui::theme::DensityKind::Comfy) + } + + /// Save density. + pub fn set_density(kind: crate::gui::theme::DensityKind) { + let mut w_config = Settings::app_config_to_update(); + w_config.density = Some(kind.id().to_string()); + w_config.save(); + } + + /// What amount previews are paired to (default USD). Migrates the legacy + /// `fiat_preview = false` to `Off`. + pub fn pairing() -> Pairing { + let r_config = Settings::app_config_to_read(); + if let Some(code) = r_config.pairing.clone() { + if let Some(p) = Pairing::from_code(&code) { + return p; + } + } + // No pairing chosen yet → off by default: no conversion is shown anywhere + // and no price is fetched until the user opts into a pairing. (A legacy + // `fiat_preview = true` still defaults to USD for existing users.) + match r_config.fiat_preview { + Some(true) => Pairing::Usd, + _ => Pairing::Off, + } + } + + /// Save the amount pairing. + pub fn set_pairing(p: Pairing) { + let mut w_config = Settings::app_config_to_update(); + w_config.pairing = Some(p.code().to_string()); + w_config.save(); + } + + /// Get identifier of the last opened wallet. + pub fn last_wallet_id() -> Option { + let r_config = Settings::app_config_to_read(); + r_config.last_wallet_id + } + + /// Save identifier of the last opened wallet. + pub fn set_last_wallet_id(id: Option) { + let mut w_config = Settings::app_config_to_update(); + w_config.last_wallet_id = id; + w_config.save(); + } + + /// Check if proxy for network requests is needed. + pub fn use_proxy() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.use_proxy.clone().unwrap_or(false) + } + + /// Enable or disable proxy for network requests. + pub fn toggle_use_proxy() { + let use_proxy = Self::use_proxy(); + let mut w_config = Settings::app_config_to_update(); + w_config.use_proxy = Some(!use_proxy); + w_config.save(); + } + + /// Check if SOCKS5 or HTTP proxy should be used. + pub fn use_socks_proxy() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.use_socks_proxy.clone().unwrap_or(true) + } + + /// Enable SOCKS5 or HTTP proxy. + pub fn toggle_use_socks_proxy() { + let use_proxy = Self::use_socks_proxy(); + let mut w_config = Settings::app_config_to_update(); + w_config.use_socks_proxy = Some(!use_proxy); + w_config.save(); + } + + /// Get SOCKS proxy URL. + pub fn socks_proxy_url() -> Option { + let r_config = Settings::app_config_to_read(); + r_config.socks_proxy_url.clone() + } + + /// Save SOCKS proxy URL. + pub fn save_socks_proxy_url(url: Option) { + let mut w_config = Settings::app_config_to_update(); + w_config.socks_proxy_url = url; + w_config.save(); + } + + /// Get HTTP proxy URL. + pub fn http_proxy_url() -> Option { + let r_config = Settings::app_config_to_read(); + r_config.http_proxy_url.clone() + } + + /// Save HTTP proxy URL. + pub fn save_http_proxy_url(url: Option) { + let mut w_config = Settings::app_config_to_update(); + w_config.http_proxy_url = url; + w_config.save(); + } + + /// Check updates on startup. + pub fn check_updates() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.check_updates.unwrap_or(false) + } + + /// Disable or enable updates checking. + pub fn toggle_check_updates() { + let check = Self::check_updates(); + // Clear update info on disable. + if !check { + Self::save_update(None); + } + let mut w_config = Settings::app_config_to_update(); + w_config.check_updates = Some(!check); + w_config.save(); + } + + /// Get last update information, that includes: version, date and description. + pub fn app_update() -> Option { + let r_config = Settings::app_config_to_read(); + r_config.app_update.clone() + } + + /// Save update information. + pub fn save_update(release: Option<&ReleaseInfo>) { + let mut w_config = Settings::app_config_to_update(); + match release { + None => { + w_config.app_update = None; + } + Some(release) => { + let url = release.url(); + if let Some(url) = url { + let app_update = AppUpdate { + version: release.version(), + size: release.size(), + date: release.date(), + changelog: release.body.clone(), + url, + }; + w_config.app_update = Some(app_update); + } + } + } + w_config.save(); + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs new file mode 100644 index 00000000..8af595ea --- /dev/null +++ b/src/settings/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +mod settings; +pub use settings::Settings; + +mod config; +pub use config::*; diff --git a/src/settings/settings.rs b/src/settings/settings.rs new file mode 100644 index 00000000..a2658d8d --- /dev/null +++ b/src/settings/settings.rs @@ -0,0 +1,187 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use grin_config::ConfigError; +use grin_core::global; +use lazy_static::lazy_static; +use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::node::NodeConfig; +use crate::settings::AppConfig; +use crate::wallet::ConnectionsConfig; + +lazy_static! { + /// Static settings state to be accessible globally. + static ref SETTINGS_STATE: Arc = Arc::new(Settings::init()); +} + +/// Contains initialized configurations. +pub struct Settings { + /// Application configuration. + app_config: Arc>, + /// Integrated node configuration. + node_config: Arc>, + /// Wallet connections configuration. + conn_config: Arc>, +} + +impl Settings { + /// Main application directory name. + pub const MAIN_DIR_NAME: &'static str = ".goblin"; + /// Application socket name. + pub const SOCKET_NAME: &'static str = "goblin.sock"; + /// Log file name. + pub const LOG_FILE_NAME: &'static str = "goblin.log"; + + /// Initialize settings with app and node configs. + fn init() -> Self { + // Initialize app config. + let app_config_path = Settings::config_path(AppConfig::FILE_NAME, None); + let app_config = Self::init_config::(app_config_path); + + // Setup chain type. + let chain_type = &app_config.chain_type; + if !global::GLOBAL_CHAIN_TYPE.is_init() { + global::init_global_chain_type(*chain_type); + } else { + global::set_global_chain_type(*chain_type); + global::set_local_chain_type(*chain_type); + } + + Self { + node_config: Arc::new(RwLock::new(NodeConfig::for_chain_type(chain_type))), + conn_config: Arc::new(RwLock::new(ConnectionsConfig::for_chain_type(chain_type))), + app_config: Arc::new(RwLock::new(app_config)), + } + } + + /// Initialize configuration from provided file path or set [`Default`] if file not exists. + pub fn init_config(path: PathBuf) -> T { + let parsed = Self::read_from_file::(path.clone()); + if !path.exists() || parsed.is_err() { + let default_config = T::default(); + Settings::write_to_file(&default_config, path); + default_config + } else { + parsed.unwrap() + } + } + + /// Get node configuration to read values. + pub fn node_config_to_read() -> RwLockReadGuard<'static, NodeConfig> { + SETTINGS_STATE.node_config.read() + } + + /// Get node configuration to update values. + pub fn node_config_to_update() -> RwLockWriteGuard<'static, NodeConfig> { + SETTINGS_STATE.node_config.write() + } + + /// Get app configuration to read values. + pub fn app_config_to_read() -> RwLockReadGuard<'static, AppConfig> { + SETTINGS_STATE.app_config.read() + } + + /// Get app configuration to update values. + pub fn app_config_to_update() -> RwLockWriteGuard<'static, AppConfig> { + SETTINGS_STATE.app_config.write() + } + + /// Get connections configuration to read values. + pub fn conn_config_to_read() -> RwLockReadGuard<'static, ConnectionsConfig> { + SETTINGS_STATE.conn_config.read() + } + + /// Get connections configuration to update values. + pub fn conn_config_to_update() -> RwLockWriteGuard<'static, ConnectionsConfig> { + SETTINGS_STATE.conn_config.write() + } + + /// Get base directory path for configuration. + pub fn base_path(sub_dir: Option) -> PathBuf { + // Check if dir exists. + let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::new()); + path.push(Self::MAIN_DIR_NAME); + if sub_dir.is_some() { + path.push(sub_dir.unwrap()); + } + // Create if the default path doesn't exist. + if !path.exists() { + let _ = fs::create_dir_all(path.clone()); + } + path + } + + /// Get log file path. + pub fn log_path() -> String { + let mut log_path = Self::base_path(None); + log_path.push(Self::LOG_FILE_NAME); + log_path.to_str().unwrap().into() + } + + /// Get desktop application socket path. + pub fn socket_path() -> PathBuf { + let mut socket_path = Self::base_path(None); + socket_path.push(Self::SOCKET_NAME); + socket_path + } + + /// Get configuration file path from provided name and subdirectory if needed. + pub fn config_path(config_name: &str, sub_dir: Option) -> PathBuf { + let mut path = Self::base_path(sub_dir); + path.push(config_name); + path + } + + /// Get path of file created when application crashed. + pub fn crash_check_path() -> PathBuf { + let mut path = Self::base_path(None); + path.push("crashed"); + path + } + + /// Delete crash file. + pub fn delete_crash_check() { + let log = Self::crash_check_path(); + if log.exists() { + let _ = fs::remove_file(log.clone()); + } + } + + /// Read configuration from the file. + pub fn read_from_file(config_path: PathBuf) -> Result { + let file_content = fs::read_to_string(config_path.clone())?; + let parsed = toml::from_str::(file_content.as_str()); + match parsed { + Ok(cfg) => Ok(cfg), + Err(e) => Err(ConfigError::ParseError( + config_path.to_str().unwrap().to_string(), + format!("{}", e), + )), + } + } + + /// Write configuration to the file. + pub fn write_to_file(config: &T, path: PathBuf) { + let conf_out = toml::to_string(config).unwrap(); + let mut file = File::create(path.to_str().unwrap()).unwrap(); + file.write_all(conf_out.as_bytes()).unwrap(); + } +} diff --git a/src/wallet/config.rs b/src/wallet/config.rs new file mode 100644 index 00000000..c512b527 --- /dev/null +++ b/src/wallet/config.rs @@ -0,0 +1,295 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use std::fs; +use std::path::PathBuf; +use std::string::ToString; + +use crate::wallet::ConnectionsConfig; +use crate::wallet::types::ConnectionMethod; +use crate::{AppConfig, Settings}; +use grin_core::global::ChainTypes; +use grin_wallet_libwallet::SlateState; +use rand::Rng; +use serde_derive::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Wallet configuration. +#[derive(Serialize, Deserialize, Clone)] +pub struct WalletConfig { + /// Current wallet account label. + pub account: String, + /// Chain type for current wallet. + pub chain_type: ChainTypes, + /// Identifier for a wallet. + pub id: i64, + /// Wallet name. + pub name: String, + /// External connection identifier. + pub ext_conn_id: Option, + /// Minimal amount of confirmations. + pub min_confirmations: u64, + /// Flag to use Dandelion to broadcast transactions. + pub use_dandelion: Option, + /// Flag to enable Tor listener on start. + pub enable_tor_listener: Option, + /// Wallet API port. + pub api_port: Option, + /// Delay in blocks before another transaction broadcasting attempt. + pub tx_broadcast_timeout: Option, + + /// Wallet data directory path. + pub data_path: Option, + + /// Config version. + ver: Option, +} + +/// Base wallets directory name. +const BASE_DIR_NAME: &'static str = "wallets"; +/// Base wallets directory name. +const DB_DIR_NAME: &'static str = "db"; +/// Wallet configuration file name. +const CONFIG_FILE_NAME: &'static str = "grim-wallet.toml"; +/// Slatepacks directory name. +const SLATEPACKS_DIR_NAME: &'static str = "slatepacks"; +/// Seed file name. +const SEED_FILE: &str = "wallet.seed"; + +/// Default value of minimal amount of confirmations. +const MIN_CONFIRMATIONS_DEFAULT: u64 = 10; + +const WALLET_CONFIG_VERSION: i32 = 1; + +impl WalletConfig { + /// Wallet data directory name. + pub const DATA_DIR_NAME: &'static str = "wallet_data"; + + /// Default account name value. + pub const DEFAULT_ACCOUNT_LABEL: &'static str = "default"; + + /// Default value of timeout for broadcasting transaction in blocks. + pub const BROADCASTING_TIMEOUT_DEFAULT: u64 = 10; + + /// Create new wallet config. + pub fn create(name: String, conn_method: &ConnectionMethod) -> WalletConfig { + // Setup configuration path. + let id = chrono::Utc::now().timestamp(); + let chain_type = AppConfig::chain_type(); + let config_path = Self::get_config_file_path(chain_type, id); + // Write configuration to the file. + let config = WalletConfig { + account: Self::DEFAULT_ACCOUNT_LABEL.to_string(), + chain_type, + id, + name, + ext_conn_id: match conn_method { + ConnectionMethod::Integrated => None, + ConnectionMethod::External(id, _) => Some(*id), + }, + min_confirmations: MIN_CONFIRMATIONS_DEFAULT, + use_dandelion: Some(true), + enable_tor_listener: Some(false), + api_port: Some(rand::rng().random_range(10000..30000)), + tx_broadcast_timeout: Some(Self::BROADCASTING_TIMEOUT_DEFAULT), + data_path: Some(Self::wallet_path(id.to_string())), + ver: Some(WALLET_CONFIG_VERSION), + }; + Settings::write_to_file(&config, config_path); + config + } + + /// Load config from provided wallet dir. + pub fn load(wallet_dir: PathBuf) -> Option { + let mut config_path: PathBuf = wallet_dir.clone(); + config_path.push(CONFIG_FILE_NAME); + if let Ok(mut config) = Settings::read_from_file::(config_path) { + config.migrate(); + return Some(config); + } + None + } + + /// Get wallet name by provided identifier. + pub fn read_name_by_id(id: i64) -> Option { + let mut wallet_dir = WalletConfig::get_base_path(AppConfig::chain_type()); + wallet_dir.push(id.to_string()); + if let Some(cfg) = Self::load(wallet_dir) { + return Some(cfg.name); + } + None + } + + /// Get wallet API port by provided identifier. + pub fn read_api_port_by_id(id: i64) -> Option { + let mut wallet_dir = WalletConfig::get_base_path(AppConfig::chain_type()); + wallet_dir.push(id.to_string()); + if let Some(cfg) = Self::load(wallet_dir) { + return cfg.api_port; + } + None + } + + /// Get wallet connection method. + pub fn connection(&self) -> ConnectionMethod { + if let Some(ext_conn_id) = self.ext_conn_id { + if let Some(conn) = ConnectionsConfig::ext_conn(ext_conn_id) { + return ConnectionMethod::External(conn.id, conn.url); + } + } + ConnectionMethod::Integrated + } + + /// Save wallet config. + pub fn save(&self) { + let config_path = Self::get_config_file_path(self.chain_type, self.id); + Settings::write_to_file(self, config_path); + } + + /// Get wallets base directory path for provided [`ChainTypes`]. + pub fn get_base_path(chain_type: ChainTypes) -> PathBuf { + let sub_dir = Some(chain_type.shortname()); + let mut wallets_path = Settings::base_path(sub_dir); + wallets_path.push(BASE_DIR_NAME); + // Create wallets base directory if it doesn't exist. + if !wallets_path.exists() { + let _ = fs::create_dir_all(wallets_path.clone()); + } + wallets_path + } + + /// Get config file path for provided [`ChainTypes`] and wallet identifier. + fn get_config_file_path(chain_type: ChainTypes, id: i64) -> PathBuf { + let mut config_path = Self::get_base_path(chain_type); + config_path.push(id.to_string()); + // Create if the config path doesn't exist. + if !config_path.exists() { + let _ = fs::create_dir_all(config_path.clone()); + } + config_path.push(CONFIG_FILE_NAME); + config_path + } + + /// Get wallet path from provided identifier. + fn wallet_path(id: String) -> String { + let chain_type = AppConfig::chain_type(); + let mut data_path = Self::get_base_path(chain_type); + data_path.push(id); + data_path.to_str().unwrap().to_string() + } + + /// Get current wallet path. + pub fn get_wallet_path(&self) -> String { + Self::wallet_path(self.id.to_string()) + } + + /// Get wallet data path. + pub fn get_data_path(&self) -> String { + if let Some(path) = &self.data_path { + path.clone() + } else { + self.get_wallet_path() + } + } + + /// Get base wallet data path. + fn get_base_data_path(&self) -> String { + let mut path = PathBuf::from(self.get_data_path()); + path.push(Self::DATA_DIR_NAME); + if !path.exists() { + let _ = fs::create_dir_all(path.clone()); + } + path.to_str().unwrap().to_string() + } + + /// Get wallet seed path. + pub fn seed_path(&self) -> String { + let mut path = PathBuf::from(self.get_base_data_path()); + path.push(SEED_FILE); + path.to_str().unwrap().to_string() + } + + /// Get wallet database data path. + pub fn get_db_path(&self) -> String { + let mut path = PathBuf::from(self.get_base_data_path()); + path.push(DB_DIR_NAME); + path.to_str().unwrap().to_string() + } + + /// Get nostr identity directory path (holds identity.json). + pub fn get_nostr_path(&self) -> PathBuf { + let mut path = PathBuf::from(self.get_base_data_path()); + path.push("nostr"); + if !path.exists() { + let _ = fs::create_dir_all(path.clone()); + } + path + } + + /// Get nostr metadata archive database path. + pub fn get_nostr_db_path(&self) -> PathBuf { + let mut path = self.get_nostr_path(); + path.push("db"); + if !path.exists() { + let _ = fs::create_dir_all(path.clone()); + } + path + } + + /// Get Slatepack file path for Slate. + pub fn get_slate_path(&self, id: Uuid, state: &SlateState) -> PathBuf { + let mut path = PathBuf::from(self.get_base_data_path()); + path.push(SLATEPACKS_DIR_NAME); + if !path.exists() { + let _ = fs::create_dir_all(path.clone()); + } + let file = format!("{}.{}.slatepack", id, state); + path.push(file); + path + } + + /// Get path to extra db storage. + pub fn get_extra_db_path(&self) -> PathBuf { + let mut path = PathBuf::from(self.get_db_path()); + path.push("extra"); + if !path.exists() { + let _ = fs::create_dir_all(path.clone()); + } + path + } + + /// Check config version to migrate if needed. + fn migrate(&mut self) { + match self.ver { + None => { + // Migrate Slatepack data. + let mut old_slate_path = PathBuf::from(self.get_wallet_path()); + old_slate_path.push(SLATEPACKS_DIR_NAME); + if old_slate_path.exists() { + let mut new_slate_path = PathBuf::from(self.get_data_path()); + new_slate_path.push(SLATEPACKS_DIR_NAME); + let _ = fs::rename(&old_slate_path, &new_slate_path); + } + // Write data path to config. + if self.data_path.is_none() { + self.data_path = Some(self.get_wallet_path()); + } + // Migrate to 1st version. + self.ver = Some(1); + } + Some(_) => {} + } + self.save(); + } +} diff --git a/src/wallet/connections/config.rs b/src/wallet/connections/config.rs new file mode 100644 index 00000000..9e3abf14 --- /dev/null +++ b/src/wallet/connections/config.rs @@ -0,0 +1,113 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use grin_core::global::ChainTypes; +use serde_derive::{Deserialize, Serialize}; + +use crate::wallet::ExternalConnection; +use crate::{AppConfig, Settings}; + +/// Wallet connections configuration. +#[derive(Serialize, Deserialize, Clone)] +pub struct ConnectionsConfig { + /// Network type for connections. + chain_type: ChainTypes, + /// URLs of external connections for wallets. + external: Vec, +} + +impl ConnectionsConfig { + /// Wallet connections configuration file name. + pub const FILE_NAME: &'static str = "connections.toml"; + + /// Initialize configuration for provided [`ChainTypes`]. + pub fn for_chain_type(chain_type: &ChainTypes) -> Self { + let path = Settings::config_path(Self::FILE_NAME, Some(chain_type.shortname())); + let parsed = Settings::read_from_file::(path.clone()); + if !path.exists() || parsed.is_err() { + let default_config = ConnectionsConfig { + chain_type: *chain_type, + external: ExternalConnection::default(chain_type), + }; + Settings::write_to_file(&default_config, path); + default_config + } else { + parsed.unwrap() + } + } + + /// Save connections configuration. + pub fn save(&mut self) { + let config = self.clone(); + let sub_dir = Some(AppConfig::chain_type().shortname()); + Settings::write_to_file(&config, Settings::config_path(Self::FILE_NAME, sub_dir)); + } + + /// Get [`ExternalConnection`] list. + pub fn ext_conn_list() -> Vec { + let r_config = Settings::conn_config_to_read(); + r_config.external.clone() + } + + /// Save [`ExternalConnection`] in configuration. + pub fn add_ext_conn(conn: ExternalConnection) { + let mut w_config = Settings::conn_config_to_update(); + if let Some(pos) = w_config + .external + .iter() + .position(|c| c.id == conn.id || c.url == conn.url) + { + w_config.external.remove(pos); + w_config.external.insert(pos, conn); + } else { + w_config.external.push(conn); + } + w_config.save(); + } + + /// Get external node connection with provided identifier. + pub fn ext_conn(id: i64) -> Option { + let r_config = Settings::conn_config_to_read(); + for c in &r_config.external { + if c.id == id { + return Some(c.clone()); + } + } + None + } + + /// Set [`ExternalConnection`] availability flag. + pub fn update_ext_conn_status(id: i64, available: Option) { + let mut w_config = Settings::conn_config_to_update(); + for c in w_config.external.iter_mut() { + if c.id == id { + c.available = available; + w_config.save(); + break; + } + } + } + + /// Remove [`ExternalConnection`] with provided identifier. + pub fn remove_ext_conn(id: i64) { + let mut w_config = Settings::conn_config_to_update(); + w_config.external = w_config + .external + .iter() + .filter(|c| c.id != id) + .cloned() + .collect(); + w_config.save(); + } +} diff --git a/src/wallet/connections/external.rs b/src/wallet/connections/external.rs new file mode 100644 index 00000000..2b59279c --- /dev/null +++ b/src/wallet/connections/external.rs @@ -0,0 +1,180 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use bytes::Bytes; +use grin_core::global::ChainTypes; +use grin_util::to_base64; +use http_body_util::Full; +use serde_derive::{Deserialize, Serialize}; + +use crate::http::HttpClient; +use crate::wallet::ConnectionsConfig; + +/// External connection for the wallet. +#[derive(Serialize, Deserialize, Clone, PartialEq)] +pub struct ExternalConnection { + /// Connection identifier. + pub id: i64, + /// Node URL. + pub url: String, + /// Optional username. + pub username: Option, + /// Optional API secret key. + pub secret: Option, + + /// Flag to check if server is available. + #[serde(skip_serializing, skip_deserializing)] + pub available: Option, +} + +/// Default external node URLs for main network. api.grin.money leads (verified +/// healthy; grincoin.org's node was returning "rpc call failed"); main.us-ea.st +/// is the Goblin-run node. The rest are independent public nodes so a single +/// operator going down never strands the wallet. +const DEFAULT_MAIN_URLS: [&'static str; 6] = [ + "https://api.grin.money", + "https://main.us-ea.st", + "https://grincoin.org", + "https://main.gri.mw", + "https://mainnet.grinffindor.org", + "https://main.grin.raubritter.org", +]; + +/// Default external node URLs for the test network — the testnet counterparts of +/// the same public operators, so testers get the same redundancy. +const DEFAULT_TEST_URLS: [&'static str; 6] = [ + "https://test.gri.mw", + "https://testnet.grincoin.org", + "https://testnet.grinffindor.org", + "https://test.us-ea.st", + "https://test.grin.raubritter.org", + "https://testapi.grin.money", +]; + +impl ExternalConnection { + /// Get default connections for provided chain type. + pub fn default(chain_type: &ChainTypes) -> Vec { + let urls = match chain_type { + ChainTypes::Mainnet => DEFAULT_MAIN_URLS.to_vec(), + _ => DEFAULT_TEST_URLS.to_vec(), + }; + urls.iter() + .enumerate() + .map(|(index, url)| ExternalConnection { + id: index as i64, + url: url.to_string(), + username: Some("grin".to_string()), + secret: None, + available: None, + }) + .collect::>() + } + + /// Create new external connection. + pub fn new(url: String, username: Option, secret: Option) -> Self { + let id = chrono::Utc::now().timestamp(); + Self { + id, + url, + username, + secret, + available: None, + } + } + + /// Check external connection availability. + pub fn check(id: Option, ui_ctx: &egui::Context) { + let conn_list = ConnectionsConfig::ext_conn_list(); + for conn in conn_list { + if let Some(id) = id { + if id == conn.id { + check_ext_conn(&conn, ui_ctx); + } + } else { + check_ext_conn(&conn, ui_ctx); + } + } + } +} + +/// Check connection availability. +fn check_ext_conn(conn: &ExternalConnection, ui_ctx: &egui::Context) { + let conn = conn.clone(); + let ui_ctx = ui_ctx.clone(); + ConnectionsConfig::update_ext_conn_status(conn.id, None); + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + // Check URL format. + let conn_url = url::Url::parse(conn.url.as_str()); + let url_res = match conn_url { + Ok(url) => Some(url), + Err(_) => { + let mut url_res = None; + if !conn.url.starts_with("http") { + let mut conn = conn.clone(); + let url_text = format!("https://{}", conn.url); + let url = url::Url::parse(url_text.as_str()); + if let Ok(url) = url { + url_res = Some(url); + conn.url = url_text; + ConnectionsConfig::add_ext_conn(conn.clone()); + } + } + url_res + } + }; + if url_res.is_none() { + ConnectionsConfig::remove_ext_conn(conn.id); + return; + } + let url = url_res.unwrap(); + if let Ok(_) = url.socket_addrs(|| None) { + let addr = format!("{}v2/foreign", url.to_string()); + let mut req_setup = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(addr.clone()); + // Setup secret key auth. + if let Some(key) = conn.secret { + let username = conn.username.unwrap_or("grin".to_string()); + let basic_auth = + format!("Basic {}", to_base64(&format!("{}:{}", username, key))); + req_setup = + req_setup.header(hyper::header::AUTHORIZATION, basic_auth.clone()); + } + let req: hyper::Request> = req_setup + .body(Full::from( + r#"{"id":1,"jsonrpc":"2.0","method":"get_version","params":{} }"#, + )) + .unwrap(); + // Send request. + match HttpClient::send(req).await { + Ok(res) => { + let status = res.status().as_u16(); + // Available on 200 HTTP status code. + ConnectionsConfig::update_ext_conn_status(conn.id, Some(status == 200)); + } + Err(_) => ConnectionsConfig::update_ext_conn_status(conn.id, Some(false)), + } + } else { + ConnectionsConfig::update_ext_conn_status(conn.id, Some(false)); + } + // Repaint ui on change. + ui_ctx.request_repaint(); + }); + }); +} diff --git a/src/wallet/connections/mod.rs b/src/wallet/connections/mod.rs new file mode 100644 index 00000000..2f68f822 --- /dev/null +++ b/src/wallet/connections/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +mod external; +pub use external::ExternalConnection; + +mod config; +pub use config::ConnectionsConfig; diff --git a/src/wallet/list.rs b/src/wallet/list.rs new file mode 100644 index 00000000..85c8e727 --- /dev/null +++ b/src/wallet/list.rs @@ -0,0 +1,125 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use grin_core::global::ChainTypes; + +use crate::AppConfig; +use crate::wallet::{Wallet, WalletConfig}; + +/// [`Wallet`] list container. +#[derive(Clone)] +pub struct WalletList { + /// List of wallets for [`ChainTypes::Mainnet`]. + pub main_list: Vec, + /// List of wallets for [`ChainTypes::Testnet`]. + pub test_list: Vec, + + /// Selected wallet id. + selected: Option, +} + +impl Default for WalletList { + fn default() -> Self { + let (main_list, test_list) = Self::init(); + Self { + main_list, + test_list, + selected: None, + } + } +} + +impl WalletList { + /// Initialize [`Wallet`] lists for [`ChainTypes::Mainnet`] and [`ChainTypes::Testnet`]. + fn init() -> (Vec, Vec) { + let mut main_wallets = Vec::new(); + let mut test_wallets = Vec::new(); + let chain_types = vec![ChainTypes::Mainnet, ChainTypes::Testnet]; + for chain in chain_types { + // initialize wallets from base directory. + let wallets_dir = WalletConfig::get_base_path(chain); + for dir in wallets_dir.read_dir().unwrap() { + let wallet_dir = dir.unwrap().path(); + if wallet_dir.is_dir() { + let wallet = Wallet::init(wallet_dir); + if let Some(w) = wallet { + if chain == ChainTypes::Testnet { + test_wallets.push(w); + } else if chain == ChainTypes::Mainnet { + main_wallets.push(w); + } + } + } + } + } + // Sort wallets by id. + main_wallets.sort_by_key(|w| -w.get_config().id); + test_wallets.sort_by_key(|w| -w.get_config().id); + (main_wallets, test_wallets) + } + + /// Get [`Wallet`] list for current [`ChainTypes`]. + pub fn list(&self) -> &Vec { + if AppConfig::chain_type() == ChainTypes::Mainnet { + &self.main_list + } else { + &self.test_list + } + } + + /// Get mutable [`Wallet`] list for current [`ChainTypes`]. + pub fn mut_list(&mut self) -> &mut Vec { + if AppConfig::chain_type() == ChainTypes::Mainnet { + &mut self.main_list + } else { + &mut self.test_list + } + } + + /// Add created [`Wallet`] to the list. + pub fn add(&mut self, wallet: Wallet) { + let list = self.mut_list(); + list.insert(0, wallet); + } + + /// Remove [`Wallet`] with provided identifier. + pub fn remove(&mut self, id: i64) { + let list = self.mut_list(); + for (index, wallet) in list.iter().enumerate() { + if wallet.get_config().id == id { + list.remove(index); + return; + } + } + } + + /// Select wallet. + pub fn select(&mut self, id: Option) { + self.selected = id; + } + + /// Get selected wallet. + pub fn selected(&self) -> Box> { + if self.selected.is_none() { + return Box::new(None); + } + let list = self.list(); + for wallet in list { + if wallet.get_config().id == self.selected.unwrap() { + return Box::new(Some(wallet)); + } + } + Box::new(None) + } +} diff --git a/src/wallet/mnemonic.rs b/src/wallet/mnemonic.rs new file mode 100644 index 00000000..b7db5072 --- /dev/null +++ b/src/wallet/mnemonic.rs @@ -0,0 +1,256 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use grin_keychain::mnemonic::{from_entropy, search, to_entropy}; +use grin_util::ZeroingString; +use rand::Rng; + +use crate::wallet::types::{PhraseMode, PhraseSize, PhraseWord}; + +/// Mnemonic phrase container. +pub struct Mnemonic { + /// Phrase setup mode. + mode: PhraseMode, + /// Size of phrase based on words count. + size: PhraseSize, + /// Generated words. + words: Vec, + /// Words to confirm the phrase. + confirmation: Vec, + /// Flag to check if entered phrase if valid. + valid: bool, +} + +impl Default for Mnemonic { + fn default() -> Self { + let size = PhraseSize::Words24; + let mode = PhraseMode::Generate; + let words = Self::generate_words(&mode, &size); + let confirmation = Self::empty_words(&size); + Self { + mode, + size, + words, + confirmation, + valid: true, + } + } +} + +impl Mnemonic { + /// Generate words based on provided [`PhraseMode`]. + pub fn set_mode(&mut self, mode: PhraseMode) { + self.mode = mode; + self.words = Self::generate_words(&self.mode, &self.size); + self.confirmation = Self::empty_words(&self.size); + self.valid = true; + } + + /// Get current phrase mode. + pub fn mode(&self) -> PhraseMode { + self.mode.clone() + } + + /// Generate words based on provided [`PhraseSize`]. + pub fn set_size(&mut self, size: PhraseSize) { + self.size = size; + self.words = Self::generate_words(&self.mode, &self.size); + self.confirmation = Self::empty_words(&self.size); + self.valid = true; + } + + /// Get current phrase size. + pub fn size(&self) -> PhraseSize { + self.size.clone() + } + + /// Get words based on current [`PhraseMode`]. + pub fn words(&self, edit: bool) -> Vec { + match self.mode { + PhraseMode::Generate => { + if edit { + &self.confirmation + } else { + &self.words + } + } + PhraseMode::Import => &self.words, + } + .clone() + } + + /// Check if current phrase is valid. + pub fn valid(&self) -> bool { + self.valid + } + + /// Get phrase from words. + pub fn get_phrase(&self) -> String { + self.words + .iter() + .enumerate() + .map(|(i, x)| if i == 0 { "" } else { " " }.to_owned() + &x.text) + .collect::() + } + + /// Generate [`PhraseWord`] list based on provided [`PhraseMode`] and [`PhraseSize`]. + fn generate_words(mode: &PhraseMode, size: &PhraseSize) -> Vec { + match mode { + PhraseMode::Generate => { + let mut rng = rand::rng(); + let mut entropy: Vec = Vec::with_capacity(size.entropy_size()); + for _ in 0..size.entropy_size() { + entropy.push(rng.random()); + } + from_entropy(&entropy) + .unwrap() + .split(" ") + .map(|s| { + let text = s.to_string(); + PhraseWord { text, valid: true } + }) + .collect::>() + } + PhraseMode::Import => Self::empty_words(size), + } + } + + /// Generate empty list of words based on provided [`PhraseSize`]. + fn empty_words(size: &PhraseSize) -> Vec { + let mut words = Vec::with_capacity(size.value()); + for _ in 0..size.value() { + words.push(PhraseWord { + text: "".to_string(), + valid: true, + }); + } + words + } + + /// Insert word into provided index and return validation result. + pub fn insert(&mut self, index: usize, word: &String) -> bool { + // Check if word is valid. + let found = search(word).is_ok(); + if !found { + return false; + } + let is_confirmation = self.mode == PhraseMode::Generate; + if is_confirmation { + let w = self.words.get(index).unwrap(); + if word != &w.text { + return false; + } + } + + // Save valid word at list. + let words = if is_confirmation { + &mut self.confirmation + } else { + &mut self.words + }; + words.remove(index); + words.insert( + index, + PhraseWord { + text: word.to_owned(), + valid: true, + }, + ); + + // Validate phrase when all words are entered. + let mut has_empty = false; + let _: Vec<_> = words + .iter() + .map(|w| { + if w.text.is_empty() { + has_empty = true; + } + }) + .collect(); + if !has_empty { + self.valid = to_entropy(self.get_phrase().as_str()).is_ok(); + } + true + } + + /// Get word from provided index. + pub fn get(&self, index: usize) -> Option { + let words = match self.mode { + PhraseMode::Generate => &self.confirmation, + PhraseMode::Import => &self.words, + }; + let word = words.get(index); + if let Some(w) = word { + return Some(PhraseWord { + text: w.text.clone(), + valid: w.valid, + }); + } + None + } + + /// Setup phrase from provided text if possible. + pub fn import(&mut self, text: &ZeroingString) { + let words_split = text.trim().split(" "); + let count = words_split.clone().count(); + if let Some(size) = PhraseSize::type_for_value(count) { + // Setup phrase size. + let confirm = self.mode == PhraseMode::Generate; + if !confirm { + self.words = Self::empty_words(&size); + self.size = size; + } else if self.size != size { + return; + } + + // Setup word list. + let mut words = vec![]; + words_split.for_each(|w| { + let mut text = w.to_string(); + text.retain(|c| c.is_alphabetic()); + let valid = search(&text).is_ok(); + words.push(PhraseWord { text, valid }); + }); + let mut has_invalid = false; + for (i, w) in words.iter().enumerate() { + if !self.insert(i, &w.text) { + has_invalid = true; + } + } + self.valid = !has_invalid; + } + } + + /// Check if phrase has invalid or empty words. + pub fn has_empty_or_invalid(&self) -> bool { + let words = match self.mode { + PhraseMode::Generate => &self.confirmation, + PhraseMode::Import => &self.words, + }; + let mut has_empty = false; + let mut has_invalid = false; + let _: Vec<_> = words + .iter() + .map(|w| { + if w.text.is_empty() { + has_empty = true; + } + if !w.valid { + has_invalid = true; + } + }) + .collect(); + has_empty || has_invalid + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs new file mode 100644 index 00000000..8884aaaf --- /dev/null +++ b/src/wallet/mod.rs @@ -0,0 +1,36 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +pub mod types; + +mod mnemonic; +pub use mnemonic::Mnemonic; + +mod connections; +pub use connections::*; + +mod wallet; +pub use wallet::*; + +mod config; +pub use config::*; + +mod list; +pub use list::*; + +mod utils; +pub use utils::WalletUtils; + +mod seed; +pub mod store; diff --git a/src/wallet/seed.rs b/src/wallet/seed.rs new file mode 100644 index 00000000..509134a7 --- /dev/null +++ b/src/wallet/seed.rs @@ -0,0 +1,102 @@ +// Copyright 2025 The Grim Developers +// +// 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. + +use core::num::NonZeroU32; +use grin_util::{ToHex, ZeroingString}; +use grin_wallet_impls::Error; +use rand::{Rng, rng}; +use serde_derive::{Deserialize, Serialize}; +use serde_json; +use std::fs::File; +use std::io::Write; + +use ring::aead; +use ring::pbkdf2; + +#[derive(Clone, Debug, PartialEq)] +pub struct WalletSeed(Vec); + +impl WalletSeed { + pub fn from_bytes(bytes: &[u8]) -> WalletSeed { + WalletSeed(bytes.to_vec()) + } + + pub fn from_mnemonic(word_list: ZeroingString) -> Result { + let res = grin_keychain::mnemonic::to_entropy(&word_list); + match res { + Ok(s) => Ok(WalletSeed::from_bytes(&s)), + Err(_) => Err(Error::Mnemonic.into()), + } + } + + pub fn init_file( + seed_file_path: &str, + recovery_phrase: ZeroingString, + password: ZeroingString, + ) -> Result { + let seed = WalletSeed::from_mnemonic(recovery_phrase)?; + let enc_seed = EncryptedWalletSeed::from_seed(&seed, password)?; + let enc_seed_json = serde_json::to_string_pretty(&enc_seed).map_err(|_| Error::Format)?; + let mut file = File::create(seed_file_path).map_err(|_| Error::IO)?; + file.write_all(&enc_seed_json.as_bytes()) + .map_err(|_| Error::IO)?; + Ok(seed) + } +} + +/// Encrypted wallet seed, for storing on disk and decrypting with provided password. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct EncryptedWalletSeed { + encrypted_seed: String, + pub salt: String, + pub nonce: String, +} + +impl EncryptedWalletSeed { + /// Create a new encrypted seed from the given seed + password. + pub fn from_seed( + seed: &WalletSeed, + password: ZeroingString, + ) -> Result { + let salt: [u8; 8] = rng().random(); + let nonce: [u8; 12] = rng().random(); + let password = password.as_bytes(); + let mut key = [0; 32]; + pbkdf2::derive( + pbkdf2::PBKDF2_HMAC_SHA512, + NonZeroU32::new(100).unwrap(), + &salt, + password, + &mut key, + ); + let content = seed.0.to_vec(); + let mut enc_bytes = content; + let unbound_key = aead::UnboundKey::new(&aead::CHACHA20_POLY1305, &key).unwrap(); + let sealing_key: aead::LessSafeKey = aead::LessSafeKey::new(unbound_key); + let aad = aead::Aad::from(&[]); + let res = sealing_key.seal_in_place_append_tag( + aead::Nonce::assume_unique_for_key(nonce), + aad, + &mut enc_bytes, + ); + if let Err(_) = res { + return Err(Error::Encryption); + } + Ok(EncryptedWalletSeed { + encrypted_seed: enc_bytes.to_hex(), + salt: salt.to_hex(), + nonce: nonce.to_hex(), + }) + } +} diff --git a/src/wallet/store.rs b/src/wallet/store.rs new file mode 100644 index 00000000..2aec96cb --- /dev/null +++ b/src/wallet/store.rs @@ -0,0 +1,115 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use rkv::backend::{SafeMode, SafeModeDatabase, SafeModeEnvironment}; +use rkv::{Manager, Rkv, SingleStore, StoreOptions, Value}; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +/// Transaction height storage. +pub struct TxHeightStore { + env: Arc>>, + /// Confirmed heights. + confirmed: SingleStore, + /// Broadcasting heights. + broadcasting: SingleStore, +} + +impl TxHeightStore { + /// Create new transaction height storage from provided directory. + pub fn new(dir: PathBuf) -> Self { + let mut manager = Manager::::singleton().write().unwrap(); + // Headroom above the stores below; see NostrStore::new on rkv SafeMode + // rejecting reopens of an env already at capacity. + let created_arc = manager + .get_or_create(dir.as_path(), |p: &std::path::Path| { + Rkv::with_capacity::(p, 16) + }) + .unwrap(); + let env = created_arc.clone(); + let k = created_arc.read().unwrap(); + + let confirmed = k.open_single("tx_height", StoreOptions::create()).unwrap(); + let broadcasting = k + .open_single("broadcast_tx_height", StoreOptions::create()) + .unwrap(); + Self { + env, + confirmed, + broadcasting, + } + } + + /// Read transaction height from database. + pub fn read_tx_height(&self, slate_id: &String) -> Option { + let env = self.env.read().unwrap(); + let reader = env.read().unwrap(); + if let Ok(value) = self.confirmed.get(&reader, slate_id) { + if let Some(height) = value { + return match height { + Value::U64(v) => Some(v), + _ => None, + }; + } + return None; + } + None + } + + /// Write transaction height to database. + pub fn write_tx_height(&self, slate_id: &String, height: u64) { + let env = self.env.read().unwrap(); + let mut writer = env.write().unwrap(); + self.confirmed + .put(&mut writer, slate_id, &Value::U64(height)) + .unwrap(); + writer.commit().unwrap(); + } + + /// Read broadcasting height from database. + pub fn read_broadcasting_height(&self, slate_id: &String) -> Option { + let env = self.env.read().unwrap(); + let reader = env.read().unwrap(); + if let Ok(value) = self.broadcasting.get(&reader, slate_id) { + if let Some(height) = value { + return match height { + Value::U64(v) => Some(v), + _ => None, + }; + } + return None; + } + None + } + + /// Write broadcasting height to database. + pub fn write_broadcasting_height(&self, slate_id: &String, height: u64) { + let env = self.env.read().unwrap(); + let mut writer = env.write().unwrap(); + self.broadcasting + .put(&mut writer, slate_id, &Value::U64(height)) + .unwrap(); + writer.commit().unwrap(); + } + + /// Delete broadcasting height from database. + pub fn delete_broadcasting_height(&self, slate_id: &String) { + let env = self.env.read().unwrap(); + let mut writer = env.write().unwrap(); + self.broadcasting + .delete(&mut writer, slate_id) + .unwrap_or_default(); + writer.commit().unwrap(); + } +} diff --git a/src/wallet/types.rs b/src/wallet/types.rs new file mode 100644 index 00000000..c8de83fd --- /dev/null +++ b/src/wallet/types.rs @@ -0,0 +1,468 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use grin_keychain::ExtKeychain; +use grin_util::Mutex; +use grin_wallet_impls::{DefaultLCProvider, HTTPNodeClient}; +use grin_wallet_libwallet::{ + Error, PaymentProof, SlateState, SlatepackAddress, TxLogEntry, TxLogEntryType, WalletInfo, + WalletInst, +}; +use grin_wallet_util::OnionV3Address; +use serde_derive::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::wallet::Wallet; + +/// Mnemonic phrase word. +#[derive(Clone)] +pub struct PhraseWord { + /// Word text. + pub text: String, + /// Flag to check if word is valid. + pub valid: bool, +} + +/// Mnemonic phrase setup mode. +#[derive(PartialEq, Clone)] +pub enum PhraseMode { + /// Generate new mnemonic phrase. + Generate, + /// Import existing mnemonic phrase. + Import, +} + +/// Mnemonic phrase size based on entropy. +#[derive(PartialEq, Clone)] +pub enum PhraseSize { + Words12, + Words15, + Words18, + Words21, + Words24, +} + +impl PhraseSize { + pub const VALUES: [PhraseSize; 5] = [ + PhraseSize::Words12, + PhraseSize::Words15, + PhraseSize::Words18, + PhraseSize::Words21, + PhraseSize::Words24, + ]; + + /// Get entropy value. + pub fn value(&self) -> usize { + match *self { + PhraseSize::Words12 => 12, + PhraseSize::Words15 => 15, + PhraseSize::Words18 => 18, + PhraseSize::Words21 => 21, + PhraseSize::Words24 => 24, + } + } + + /// Get entropy size for current phrase size. + pub fn entropy_size(&self) -> usize { + match *self { + PhraseSize::Words12 => 16, + PhraseSize::Words15 => 20, + PhraseSize::Words18 => 24, + PhraseSize::Words21 => 28, + PhraseSize::Words24 => 32, + } + } + + /// Get phrase type for entropy size. + pub fn type_for_value(count: usize) -> Option { + if Self::is_correct_count(count) { + match count { + 12 => Some(PhraseSize::Words12), + 15 => Some(PhraseSize::Words15), + 18 => Some(PhraseSize::Words18), + 21 => Some(PhraseSize::Words21), + 24 => Some(PhraseSize::Words24), + _ => None, + } + } else { + None + } + } + + /// Check if correct entropy size was provided. + pub fn is_correct_count(count: usize) -> bool { + count == 12 || count == 15 || count == 18 || count == 21 || count == 24 + } +} + +/// Wallet connection method. +#[derive(Serialize, Deserialize, Clone, PartialEq)] +pub enum ConnectionMethod { + /// Integrated node. + Integrated, + /// External node, contains connection identifier and URL. + External(i64, String), +} + +/// Wallet instance type. +pub type WalletInstance = Arc< + Mutex< + Box< + dyn WalletInst< + 'static, + DefaultLCProvider, + HTTPNodeClient, + ExtKeychain, + >, + >, + >, +>; + +/// Wallet account data. +#[derive(Clone)] +pub struct WalletAccount { + /// Spendable balance amount. + pub spendable_amount: u64, + /// Account label. + pub label: String, + /// Account BIP32 derivation path. + pub path: String, +} + +/// Wallet balance and transactions data. +#[derive(Clone)] +pub struct WalletData { + /// Balance data for current account. + pub info: WalletInfo, + + /// Transactions data. + pub txs: Option>, + /// Number of txs to show on select from database. + pub txs_limit: u32, +} + +impl WalletData { + /// Number of transactions per select to show at list. + pub const TXS_LIMIT: u32 = 30; + + /// Update transaction action status. + pub fn on_tx_action(&mut self, id: u32, action: Option) { + if self.txs.is_none() { + return; + } + for tx in self.txs.as_mut().unwrap() { + if id == tx.data.id { + tx.action = action; + tx.action_error = None; + break; + } + } + } + + /// Update transaction action error status. + pub fn on_tx_error(&mut self, id: u32, err: Option) { + if self.txs.is_none() { + return; + } + for tx in self.txs.as_mut().unwrap() { + if id == tx.data.id { + tx.action_error = err; + break; + } + } + } + + /// Get transaction by identifier. + pub fn tx_by_id(&self, id: u32) -> Option { + if self.txs.is_none() { + return None; + } + for tx in self.txs.as_ref().unwrap() { + if tx.data.id == id { + return Some(tx.clone()); + } + } + None + } +} + +/// Wallet transaction action. +#[derive(Clone, PartialEq)] +pub enum WalletTxAction { + Cancelling, + Finalizing, + Posting, + Deleting, +} + +/// Wallet transaction data. +#[derive(Clone)] +pub struct WalletTx { + /// Information from database. + pub data: TxLogEntry, + /// State of transaction Slate. + pub state: SlateState, + /// Payment proof. + pub(crate) proof: Option, + + /// Transaction amount without fees. + pub amount: u64, + /// Possible receiver of transaction. + pub receiver: Option, + /// Possible sender of transaction. + pub sender: Option, + /// Block height where tx was included. + pub height: Option, + /// Block height where tx started broadcasting. + pub broadcasting_height: Option, + + /// Action on transaction. + pub action: Option, + /// Action result error. + pub action_error: Option, +} + +impl WalletTx { + /// Create new wallet transaction. + pub fn new( + tx: TxLogEntry, + proof: Option, + wallet: &Wallet, + height: Option, + broadcasting_height: Option, + action: Option, + action_error: Option, + ) -> Self { + // For a sent tx the wallet debits inputs and credits change, so + // `debited - credited` is the amount sent PLUS the network fee. Subtract + // the fee so `amount` is the value that actually reached the recipient + // (matching what their wallet receives). Receives don't pay a fee. + let fee = tx.fee.map(|f| f.fee()).unwrap_or(0); + let amount = if tx.amount_debited > tx.amount_credited { + (tx.amount_debited - tx.amount_credited).saturating_sub(fee) + } else { + tx.amount_credited - tx.amount_debited + }; + let mut receiver: Option = None; + let mut sender: Option = None; + if let Some(proof) = &tx.payment_proof { + let rec_onion_addr = OnionV3Address::from_bytes(proof.receiver_address.to_bytes()); + if let Ok(addr) = SlatepackAddress::try_from(rec_onion_addr) { + receiver = Some(addr); + } + let send_onion_addr = OnionV3Address::from_bytes(proof.sender_address.to_bytes()); + if let Ok(addr) = SlatepackAddress::try_from(send_onion_addr) { + sender = Some(addr); + } + } + let mut t = Self { + data: tx, + state: SlateState::Unknown, + proof, + amount, + receiver, + sender, + height, + broadcasting_height, + action, + action_error, + }; + // Update Slate state for unconfirmed. + if !t.data.confirmed + && (t.data.tx_type == TxLogEntryType::TxSent + || t.data.tx_type == TxLogEntryType::TxReceived) + { + if let Some(slate_id) = t.data.tx_slate_id { + t.state = wallet.get_slate_state(slate_id, &t.data.tx_type) + } + } + t + } + + /// Check if transactions can be finalized after receiving response. + pub fn can_finalize(&self) -> bool { + !self.cancelling() + && !self.data.confirmed + && (self.data.tx_type == TxLogEntryType::TxSent + || self.data.tx_type == TxLogEntryType::TxReceived) + && (self.state == SlateState::Invoice1 || self.state == SlateState::Standard1) + } + + /// Check if transaction was finalized. + pub fn finalized(&self) -> bool { + (self.data.tx_type == TxLogEntryType::TxSent + || self.data.tx_type == TxLogEntryType::TxReceived) + && self.state == SlateState::Invoice3 + || self.state == SlateState::Standard3 + } + + /// Check if transaction is cancelling. + pub fn cancelling(&self) -> bool { + if let Some(a) = self.action.as_ref() { + return a == &WalletTxAction::Cancelling; + } + false + } + + /// Check if transaction is posting. + pub fn posting(&self) -> bool { + if let Some(a) = self.action.as_ref() { + return a == &WalletTxAction::Posting; + } + false + } + + /// Check if transaction can be cancelled. + pub fn can_cancel(&self) -> bool { + !self.cancelling() + && !self.data.confirmed + && !self.broadcasting() + && self.data.tx_type != TxLogEntryType::TxReceivedCancelled + && self.data.tx_type != TxLogEntryType::TxSentCancelled + } + + /// Check if transaction was canceled. + pub fn cancelled(&self) -> bool { + self.data.tx_type == TxLogEntryType::TxReceivedCancelled + || self.data.tx_type == TxLogEntryType::TxSentCancelled + } + + /// Check if transaction is finalizing. + pub fn finalizing(&self) -> bool { + if let Some(a) = self.action.as_ref() { + return a == &WalletTxAction::Finalizing; + } + false + } + + /// Check if possible to repeat transaction action. + pub fn can_repeat_action(&self, wallet: &Wallet) -> bool { + if let Some(a) = &self.action { + self.action_error.is_some() && a != &WalletTxAction::Cancelling + } else { + // Goblin's online payments go over nostr; there is no Tor resend. + let _ = wallet; + false + } + } + + /// Check if transaction is broadcasting after finalization. + pub fn broadcasting(&self) -> bool { + !self.data.confirmed && self.finalized() + } + + /// Check if broadcasting of transaction was timed out. + pub fn broadcasting_timed_out(&self, wallet: &Wallet) -> bool { + if let Some(data) = wallet.get_data() { + if self.broadcasting() { + let last_height = data.info.last_confirmed_height; + let broadcasting_height = self.broadcasting_height.unwrap_or(0); + if broadcasting_height == 0 { + return false; + } + let delay = wallet.broadcasting_delay(); + return last_height - broadcasting_height > delay; + } + } + false + } + + /// Check if transaction is deleting. + pub fn deleting(&self) -> bool { + if let Some(a) = self.action.as_ref() { + return a == &WalletTxAction::Deleting; + } + false + } +} + +/// Result of [`crate::wallet::Wallet::manual_process_slatepack`]: either a reply +/// slatepack the user must hand back to the counterparty, or a returned slate now +/// being finalized and posted on the worker thread. +pub enum ManualSlatepackOutcome { + /// A reply slatepack to send back (e.g. the receiver's response to a payment). + Response(String), + /// A returned slate is being finalized and posted to the chain. + Finalizing, +} + +/// Task for the wallet. +#[derive(Clone)] +pub enum WalletTask { + /// Open Slatepack message parsing result and making an action. + OpenMessage(String), + /// Calculate fee to send amount. + /// * amount + /// * fee (to read at result) + CalculateFee(u64, u64), + /// Verify payment proof. + /// * payment proof + /// * result (tx id, sender mine, receiver mine) + VerifyProof(PaymentProof, Option>), + /// Create request to send. + /// * amount + /// * receiver + Send(u64, Option), + /// Invoice creation. + /// * amount + Receive(u64), + /// Transaction finalization. + /// * tx id + Finalize(u32), + /// Post transaction to blockchain. + /// * tx id + Post(u32), + /// Cancel transaction. + /// * tx id + Cancel(u32), + /// Delete transaction. + /// * tx id + Delete(u32), + /// Send amount to a nostr contact as NIP-17 DM. + /// * amount + /// * receiver public key (hex) + /// * optional note (subject line) + NostrSend(u64, String, Option, Vec), + /// Re-dispatch the pending nostr message for transaction. + /// * tx id + NostrResend(u32), + /// Pay an APPROVED incoming payment request (explicit user action). + /// * request id (rumor event id hex) + NostrPayRequest(String), + /// Request an amount FROM a nostr contact: issue a grin Invoice1 slate and + /// deliver it as a NIP-17 DM. The recipient sees an approve-to-pay card. + /// * amount + /// * receiver public key (hex) + /// * optional note (subject line) + /// * relay hints + NostrRequest(u64, String, Option, Vec), + /// Republish our kind-0 profile (e.g. after toggling the incoming-requests + /// preference) so the change propagates to relays immediately. + NostrRepublishProfile, + /// Decline an incoming payment request: mark it declined and send the + /// requester a void control message so their side clears too. + /// * request id (rumor event id hex) + NostrDeclineRequest(String), + /// Cancel a request WE sent: cancel the local invoice tx and send the payer a + /// void control message so the pending card disappears on their side. + /// * slate id (uuid string) + NostrCancelOutgoing(String), + /// Cancel a payment WE sent that the recipient never completed: cancel the + /// local grin tx to RECLAIM the locked outputs, mark it cancelled, and send + /// the recipient a best-effort void so a late catch-up drops the dead slate. + /// Refuses (and notes "already completed") if the payment finalized in the + /// race window. + /// * slate id (uuid string) + NostrCancelSend(String), +} diff --git a/src/wallet/utils.rs b/src/wallet/utils.rs new file mode 100644 index 00000000..b875542d --- /dev/null +++ b/src/wallet/utils.rs @@ -0,0 +1,28 @@ +// Copyright 2024 The Grim Developers +// +// 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. + +use sha2::{Digest, Sha256}; + +/// Wallet utilities functions. +pub struct WalletUtils {} + +impl WalletUtils { + /// Setup entropy data checksum. + pub fn setup_checksum(data: &mut Vec) { + let mut hasher = Sha256::new(); + hasher.update(data.clone()); + let checksum = hasher.finalize(); + data.extend(checksum); + } +} diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs new file mode 100644 index 00000000..a2f9da2a --- /dev/null +++ b/src/wallet/wallet.rs @@ -0,0 +1,3150 @@ +// Copyright 2023 The Grim Developers +// +// 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. + +use crate::AppConfig; +use crate::node::{Node, NodeConfig}; +use crate::nostr::{NostrConfig, NostrIdentity, NostrService, NostrStore}; +use crate::wallet::seed::WalletSeed; +use crate::wallet::store::TxHeightStore; +use crate::wallet::types::{ + ConnectionMethod, ManualSlatepackOutcome, PhraseMode, WalletAccount, WalletData, + WalletInstance, WalletTask, WalletTx, WalletTxAction, +}; +use crate::wallet::{ConnectionsConfig, Mnemonic, WalletConfig}; + +use chrono::Utc; +use futures::channel::oneshot; +use grin_api::{ApiServer, Router}; +use grin_chain::SyncStatus; +use grin_keychain::{ExtKeychain, Keychain}; +use grin_util::secp::SecretKey; +use grin_util::types::ZeroingString; +use grin_util::{Mutex, ToHex}; +use grin_wallet_api::Owner; +use grin_wallet_controller::command::parse_slatepack; +use grin_wallet_controller::controller; +use grin_wallet_controller::controller::ForeignAPIHandlerV2; +use grin_wallet_impls::{DefaultLCProvider, DefaultWalletImpl, HTTPNodeClient}; +use grin_wallet_libwallet::api_impl::owner::{ + cancel_tx, init_send_tx, retrieve_summary_info, retrieve_txs, verify_payment_proof, +}; +use grin_wallet_libwallet::{ + Error, InitTxArgs, IssueInvoiceTxArgs, NodeClient, PaymentProof, Slate, SlateState, + SlatepackAddress, StatusMessage, StoredProofInfo, TxLogEntry, TxLogEntryType, WalletBackend, + WalletInitStatus, WalletInst, WalletLCProvider, address, +}; +use log::{error, info}; +use num_bigint::BigInt; +use parking_lot::RwLock; +use rand::Rng; +use std::fs::File; +use std::io::Write; +use std::net::{SocketAddr, TcpListener, ToSocketAddrs}; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, mpsc}; +use std::thread::Thread; +use std::time::Duration; +use std::{fs, path, thread}; +use uuid::Uuid; + +/// Contains wallet instance, configuration and state, handles wallet commands. +#[derive(Clone)] +pub struct Wallet { + /// Wallet configuration. + config: Arc>, + /// Wallet instance, initializing on wallet opening and clearing on wallet closing. + instance: Arc>>, + /// Connection of current wallet instance. + connection: Arc>, + /// Keychain mask for API calls. + keychain_mask: Arc>>, + + /// Wallet Slatepack address to receive txs at transport. + slatepack_address: Arc>>, + + /// Wallet accounts. + accounts: Arc>>, + /// Timestamp when wallet account was selected to form unique identifier for transport. + account_time: Arc, + + /// Wallet sync thread. + sync_thread: Arc>>, + /// Flag to check if wallet is syncing. + syncing: Arc, + /// Info loading progress in percents. + info_sync_progress: Arc, + /// Error on wallet loading. + sync_error: Arc, + /// Attempts amount to update wallet data. + sync_attempts: Arc, + + /// Wallet data. + data: Arc>>, + /// Flag to check if wallet data was synced from node. + from_node: Arc, + /// Flag to check if more transactions need to be loaded. + more_txs_loading: Arc, + + /// Flag to check if wallet reopening is needed. + reopen: Arc, + /// Flag to check if wallet is open. + is_open: Arc, + /// Flag to check if wallet is closing. + closing: Arc, + /// Flag to check if wallet was deleted to remove it from the list. + deleted: Arc, + + /// Running wallet foreign API server and port. + foreign_api_server: Arc>>, + /// Wallet secret key for transport service. + secret_key: Arc>>, + + /// Flag to check if wallet repairing and restoring missing outputs is needed. + repair_needed: Arc, + /// Wallet repair progress in percents. + repair_progress: Arc, + + /// Flag to check if wallet files are moving. + files_moving: Arc, + + /// Flag to check if Slatepack message file is opening. + message_opening: Arc, + + /// Amount requests to calculate fee. + fee_calculating: Arc, + /// Last calculated network fee as `(amount, fee)` so a screen can read the + /// fee for the amount it is showing without owning the task-result slot. + last_fee: Arc>>, + + /// Flag to check if sending request is creating. + send_creating: Arc, + /// Flag to check if invoice is creating. + invoice_creating: Arc, + + /// Amount requests to calculate fee. + proof_verifying: Arc, + + /// Tasks sender. + tasks_sender: Arc>>>, + /// Task result with optional transaction identifier. + task_result: Arc, WalletTask)>>>, + + /// Nostr payment-messaging service, present while wallet is open. + nostr: Arc>>>, +} + +impl Wallet { + /// Create new [`Wallet`] instance with provided [`WalletConfig`]. + fn new(config: WalletConfig) -> Self { + let connection = config.connection(); + Self { + config: Arc::new(RwLock::new(config)), + instance: Arc::new(RwLock::new(None)), + connection: Arc::new(RwLock::new(connection)), + keychain_mask: Arc::new(RwLock::new(None)), + slatepack_address: Arc::new(RwLock::new(None)), + accounts: Arc::new(RwLock::new(vec![])), + account_time: Arc::new(Default::default()), + sync_thread: Arc::from(RwLock::new(None)), + syncing: Arc::new(AtomicBool::new(false)), + info_sync_progress: Arc::from(AtomicU8::new(0)), + sync_error: Arc::from(AtomicBool::new(false)), + sync_attempts: Arc::new(AtomicU8::new(0)), + data: Arc::new(RwLock::new(None)), + from_node: Arc::new(AtomicBool::new(false)), + more_txs_loading: Arc::new(AtomicBool::new(false)), + reopen: Arc::new(AtomicBool::new(false)), + is_open: Arc::from(AtomicBool::new(false)), + closing: Arc::new(AtomicBool::new(false)), + deleted: Arc::new(AtomicBool::new(false)), + foreign_api_server: Arc::new(RwLock::new(None)), + secret_key: Arc::new(RwLock::new(None)), + repair_needed: Arc::new(AtomicBool::new(false)), + repair_progress: Arc::new(AtomicU8::new(0)), + files_moving: Arc::new(AtomicBool::new(false)), + message_opening: Arc::new(AtomicBool::from(false)), + send_creating: Arc::new(AtomicBool::new(false)), + fee_calculating: Arc::new(AtomicU8::new(0)), + last_fee: Arc::new(RwLock::new(None)), + invoice_creating: Arc::new(AtomicBool::new(false)), + proof_verifying: Arc::new(AtomicBool::new(false)), + tasks_sender: Arc::new(RwLock::new(None)), + task_result: Arc::new(RwLock::new(None)), + nostr: Arc::new(RwLock::new(None)), + } + } + + /// Create new wallet. + pub fn create( + name: &String, + password: &ZeroingString, + mnemonic: &Mnemonic, + conn_method: &ConnectionMethod, + ) -> Result { + let config = WalletConfig::create(name.clone(), conn_method); + let w = Wallet::new(config.clone()); + { + // Wallet directory setup. + let mut path = PathBuf::from(config.get_data_path()); + path.push(WalletConfig::DATA_DIR_NAME); + fs::create_dir_all(&path) + .map_err(|_| Error::IO("Directory creation error".to_string()))?; + // Create seed file. + let _ = WalletSeed::init_file( + config.seed_path().as_str(), + ZeroingString::from(mnemonic.get_phrase()), + password.clone(), + ) + .map_err(|_| Error::IO("Seed file creation error".to_string()))?; + let node_client = Self::create_node_client(&config)?; + let mut wallet: WalletBackend = + match WalletBackend::new(path.to_str().unwrap(), node_client) { + Err(_) => { + return Err(Error::Lifecycle("DB creation error".to_string()).into()); + } + Ok(d) => d, + }; + // Save init status of this wallet, to determine whether it needs a full UTXO scan. + let mut batch = wallet.batch_no_mask()?; + match mnemonic.mode() { + PhraseMode::Generate => batch.save_init_status(WalletInitStatus::InitNoScanning)?, + PhraseMode::Import => { + batch.save_init_status(WalletInitStatus::InitNeedsScanning)? + } + } + batch.commit()?; + } + Ok(w) + } + + /// Initialize [`Wallet`] from provided data path. + pub fn init(data_path: PathBuf) -> Option { + let wallet_config = WalletConfig::load(data_path); + if let Some(config) = wallet_config { + return Some(Wallet::new(config)); + } + None + } + + /// Create [`HTTPNodeClient`] from provided config. + fn create_node_client(config: &WalletConfig) -> Result { + let integrated = || { + let api_url = format!("http://{}", NodeConfig::get_api_address()); + let api_secret = NodeConfig::get_api_secret(true); + (api_url, api_secret) + }; + let (node_api_url, node_secret) = if let Some(id) = config.ext_conn_id { + if let Some(conn) = ConnectionsConfig::ext_conn(id) { + (conn.url, conn.secret) + } else { + integrated() + } + } else { + integrated() + }; + let client = if AppConfig::use_proxy() { + let socks = AppConfig::use_socks_proxy(); + let url = if socks { + AppConfig::socks_proxy_url() + } else { + AppConfig::http_proxy_url() + } + .unwrap_or("".to_string()) + .replace("http://", "") + .replace("socks5://", ""); + + // Convert URL to SocketAddr. + let addr_res = match SocketAddr::from_str(url.as_str()) { + Ok(ip_addr) => Some(ip_addr), + Err(_) => { + if let Ok(mut socket_addr_list) = url.to_socket_addrs() { + if let Some(addr) = socket_addr_list.next() { + Some(addr) + } else { + None + } + } else { + None + } + } + }; + + match addr_res { + None => HTTPNodeClient::new(&node_api_url, node_secret)?, + Some(addr) => { + let scheme = if socks { "socks5://" } else { "http://" }; + HTTPNodeClient::new_proxy(&node_api_url, node_secret, Some((addr, scheme)))? + } + } + } else { + HTTPNodeClient::new(&node_api_url, node_secret)? + }; + Ok(client) + } + + /// Create [`WalletInstance`] from provided [`WalletConfig`]. + fn create_wallet_instance(config: &mut WalletConfig) -> Result { + // Setup node client. + let node_client = Self::create_node_client(config)?; + + // Create wallet instance. + let wallet = Self::inst_wallet::< + DefaultLCProvider, + HTTPNodeClient, + ExtKeychain, + >(config, node_client)?; + Ok(wallet) + } + + /// Instantiate [`WalletInstance`] from provided node client and [`WalletConfig`]. + fn inst_wallet( + config: &mut WalletConfig, + node_client: C, + ) -> Result>>>, Error> + where + DefaultWalletImpl: WalletInst<'static, L, C, K>, + L: WalletLCProvider<'static, C, K>, + C: NodeClient + 'static, + K: Keychain + 'static, + { + let mut wallet = Box::new(DefaultWalletImpl::::new(node_client).unwrap()) + as Box>; + let lc = wallet.lc_provider()?; + lc.set_top_level_directory(config.get_data_path().as_str())?; + Ok(Arc::new(Mutex::new(wallet))) + } + + /// Open the wallet and start [`WalletData`] sync at separate thread. + pub fn open(&self, password: ZeroingString) -> Result<(), Error> { + if self.is_open() { + return Err(Error::GenericError("Already opened".to_string())); + } + // Keep a copy of the password for nostr identity setup below; the + // original is moved into open_wallet. + let nostr_password = password.clone(); + + // Create new wallet instance if sync thread was stopped or instance was not created. + let has_instance = { + let r_inst = self.instance.as_ref().read(); + r_inst.is_some() + }; + if self.sync_thread.read().is_none() || !has_instance { + let mut config = self.get_config(); + // Setup current connection. + { + let mut w_conn = self.connection.write(); + *w_conn = config.connection(); + } + let new_instance = Self::create_wallet_instance(&mut config)?; + let mut w_inst = self.instance.write(); + *w_inst = Some(new_instance); + } + + // Open the wallet. + { + let instance = { + let r_inst = self.instance.as_ref().read(); + r_inst.clone().unwrap() + }; + let mut wallet_lock = instance.lock(); + let lc = wallet_lock.lc_provider()?; + match lc.open_wallet(None, password, true, false) { + Ok(m) => { + { + let mut w_mask = self.keychain_mask.write(); + *w_mask = m; + } + // Reset an error on opening. + self.set_sync_error(false); + self.reset_sync_attempts(); + + // Set current account. + let wallet_inst = lc.wallet_inst()?; + let label = self.get_config().account.to_owned(); + wallet_inst.set_parent_key_id_by_name(label.as_str())?; + self.account_time + .store(Utc::now().timestamp(), Ordering::Relaxed); + + // Initialize the nostr identity + service BEFORE spawning the + // sync thread. The sync loop starts the service on its first + // iteration (wallet.rs, top of the loop); if the thread races + // ahead of this, that first iteration finds no service, skips + // it, and the service doesn't start until the NEXT cycle — a + // full SYNC_DELAY (60s) later. That 60s gap (not the mixnet, + // which connects a relay in ~2s) is the "stuck on Connecting… + // for a minute" symptom. Synchronous + on this thread, so the + // service is guaranteed present when start_sync runs. + self.init_nostr(&nostr_password); + + // Start new synchronization thread or wake up existing one. + let mut thread_w = self.sync_thread.write(); + if thread_w.is_none() { + let thread = start_sync(self.clone()); + *thread_w = Some(thread); + } else { + thread_w.clone().unwrap().unpark(); + } + self.is_open.store(true, Ordering::Relaxed); + } + Err(e) => { + if !self.syncing() { + let mut w_inst = self.instance.write(); + *w_inst = None; + } + return Err(e); + } + } + } + + // Update Slatepack address and secret key. + self.update_secret_key_addr()?; + + Ok(()) + } + + /// Initialize the nostr identity and service for this wallet. + /// Failures are logged and disable nostr for the session only. + /// The password is held as a [`ZeroingString`] so it is scrubbed on drop. + fn init_nostr(&self, password: &ZeroingString) { + { + let r_nostr = self.nostr.read(); + if r_nostr.is_some() { + return; + } + } + let config = self.get_config(); + let wallet_dir = PathBuf::from(config.get_data_path()); + let nostr_config = NostrConfig::load(wallet_dir); + if !nostr_config.enabled() { + return; + } + let nostr_dir = config.get_nostr_path(); + // Load the existing identity or generate a fresh RANDOM one. The key + // is deliberately independent of the wallet seed: the seed must never + // double as identity evidence, and a rotation must not be derivable + // from it. The nsec backup is therefore the identity backup. + let identity = match NostrIdentity::load(&nostr_dir) { + Some(identity) => identity, + None => match NostrIdentity::create_random(password) { + Ok((identity, _)) => { + if let Err(e) = identity.save(&nostr_dir) { + error!("nostr: identity save failed: {e}"); + return; + } + identity + } + Err(e) => { + error!("nostr: identity creation failed: {e}"); + return; + } + }, + }; + let keys = match identity.unlock(password) { + Ok(keys) => keys, + Err(e) => { + error!("nostr: identity unlock failed: {e}"); + return; + } + }; + info!("nostr: identity ready: {}", identity.npub); + let store = NostrStore::new(config.get_nostr_db_path()); + let service = NostrService::new(keys, identity, nostr_config, store, nostr_dir); + let mut w_nostr = self.nostr.write(); + *w_nostr = Some(service); + } + + /// Get the nostr service when available. + pub fn nostr_service(&self) -> Option> { + let r_nostr = self.nostr.read(); + r_nostr.clone() + } + + /// Rotate the nostr identity to a brand-new RANDOM key (no derivation + /// chain: a future seed compromise cannot reach it, and the old key + /// shares nothing with it), atomically moving the registered username + /// (if any) to the new key via the name server. Blocking (network I/O): + /// call from a worker thread. Returns the new bech32 npub. + pub fn rotate_nostr_identity(&self, password: String) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + // Snapshot the old identity and prove the password by unlocking it + // (NIP-49 decryption fails on a wrong password). + let old = svc.identity.read().clone(); + let old_keys = old + .unlock(&password) + .map_err(|_| "Wrong password".to_string())?; + + // Generate the replacement identity. + let (mut new_identity, new_keys) = NostrIdentity::create_random(&password) + .map_err(|e| format!("key generation failed: {e}"))?; + + // Release the username first (the server also deletes its avatar); + // abort the rotation if that fails so the user never ends up with a + // burned key still welded to a public name. After rotation the name + // is up for grabs — by the new key or anyone else. + if let Some(nip05) = old.nip05.clone() { + let name = nip05.split('@').next().unwrap_or_default().to_string(); + let server = { svc.config.read().nip05_server() }; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + rt.block_on(async { crate::nostr::nip05::unregister(&server, &name, &old_keys).await }) + .map_err(|e| format!("Couldn't release @{name}: {e} — rotation cancelled"))?; + } + new_identity.prev_npubs = { + let mut v = old.prev_npubs.clone(); + v.push(old.npub.clone()); + v + }; + + // Persist, then swap the running service for one bound to the new key. + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); + new_identity + .save(&nostr_dir) + .map_err(|e| format!("identity save failed: {e}"))?; + svc.stop(); + for _ in 0..100 { + if !svc.is_running() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + let wallet_dir = PathBuf::from(config.get_data_path()); + let nostr_config = NostrConfig::load(wallet_dir); + let store = NostrStore::new(config.get_nostr_db_path()); + let new_npub = new_identity.npub.clone(); + let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir); + { + let mut w_nostr = self.nostr.write(); + *w_nostr = Some(new_svc.clone()); + } + new_svc.start(self.clone()); + info!("nostr: identity rotated to {}", new_npub); + Ok(new_npub) + } + + /// Replace the nostr identity with an imported key — either a bare nsec + /// or an exported identity-backup JSON (which restores the username and + /// rotation history too). The current identity is overwritten; its npub + /// is kept in `prev_npubs` for reference. Blocking-safe (no network). + pub fn import_nostr_identity( + &self, + input: String, + password: String, + backup_password: Option, + ) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + // Prove THIS wallet's password against the current identity first. + let old = svc.identity.read().clone(); + old.unlock(&password) + .map_err(|_| "Wrong password".to_string())?; + let input = input.trim(); + let bpw = backup_password + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&password); + let (mut new_identity, new_keys) = if NostrIdentity::is_encrypted_backup(input) { + // A GOBLIN-*.backup file: fully sealed. Open it with the password it + // was created under (may differ on a new device), then RE-ENCRYPT + // under this wallet's password so future unlocks use the current one. + let (backup, keys) = + NostrIdentity::from_encrypted_backup(input, bpw).map_err(|_| { + "Couldn't open the backup — wrong password? If it was made on \ + another device, enter that wallet's password in the \ + backup-password field." + .to_string() + })?; + let mut ident = NostrIdentity::from_unlocked_keys( + &keys, + &password, + backup.source, + backup.derivation_account, + ) + .map_err(|e| format!("re-encryption failed: {e}"))?; + ident.nip05 = backup.nip05.clone(); + ident.anonymous = backup.anonymous; + ident.prev_npubs = backup.prev_npubs.clone(); + (ident, keys) + } else if input.starts_with('{') { + // Legacy plaintext identity-backup JSON (pre-.backup-file): decrypt + // with its password, then re-encrypt under this wallet's password. + let backup: NostrIdentity = + serde_json::from_str(input).map_err(|_| "Invalid identity backup".to_string())?; + let keys = backup.unlock(bpw).map_err(|_| { + "Couldn't decrypt the backup — if it was exported on another \ + device, enter that wallet's password in the backup-password \ + field" + .to_string() + })?; + let mut ident = NostrIdentity::from_unlocked_keys( + &keys, + &password, + backup.source, + backup.derivation_account, + ) + .map_err(|e| format!("re-encryption failed: {e}"))?; + ident.nip05 = backup.nip05.clone(); + ident.anonymous = backup.anonymous; + ident.prev_npubs = backup.prev_npubs.clone(); + (ident, keys) + } else { + NostrIdentity::create_imported(input, &password) + .map_err(|_| "Invalid nsec".to_string())? + }; + if new_identity.npub != old.npub && !new_identity.prev_npubs.contains(&old.npub) { + new_identity.prev_npubs.push(old.npub.clone()); + } + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); + new_identity + .save(&nostr_dir) + .map_err(|e| format!("identity save failed: {e}"))?; + svc.stop(); + for _ in 0..100 { + if !svc.is_running() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + let wallet_dir = PathBuf::from(config.get_data_path()); + let nostr_config = NostrConfig::load(wallet_dir); + let store = NostrStore::new(config.get_nostr_db_path()); + let new_npub = new_identity.npub.clone(); + let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir); + { + let mut w_nostr = self.nostr.write(); + *w_nostr = Some(new_svc.clone()); + } + new_svc.start(self.clone()); + info!("nostr: identity replaced by import: {}", new_npub); + Ok(new_npub) + } + + /// Build the contents of a `GOBLIN-*.backup` file: the whole nostr identity, + /// fully sealed under the wallet password. Verifies the password first. + pub fn create_nostr_backup(&self, password: &str) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + let identity = svc.identity.read().clone(); + let keys = identity + .unlock(password) + .map_err(|_| "Wrong password".to_string())?; + identity + .to_encrypted_backup(&keys) + .map_err(|e| format!("backup failed: {e}")) + } + + /// Get keychain mask [`SecretKey`]. + pub fn keychain_mask(&self) -> Option { + let r_key = self.keychain_mask.read(); + r_key.clone() + } + + /// Get wallet [`SecretKey`] for transport. + pub fn secret_key(&self) -> Option { + let r_key = self.secret_key.read(); + r_key.clone() + } + + /// Retrieve wallet [`SecretKey`] and Slatepack address for transport. + fn update_secret_key_addr(&self) -> Result<(), Error> { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut w_lock = instance.lock(); + let lc = w_lock.lc_provider()?; + let w_inst = lc.wallet_inst()?; + let k = w_inst.keychain(self.keychain_mask().as_ref())?; + let parent_key_id = w_inst.parent_key_id(); + let sec_key = address::address_from_derivation_path(&k, &parent_key_id, 0) + .map_err(|e| Error::TorConfig(format!("{:?}", e)))?; + let addr = SlatepackAddress::try_from(&sec_key)?; + let mut w_key = self.secret_key.write(); + *w_key = Some(sec_key); + let mut w_address = self.slatepack_address.write(); + *w_address = Some(addr.to_string()); + Ok(()) + } + + /// Get unique opened wallet identifier, including current account. + pub fn identifier(&self) -> String { + let config = self.get_config(); + let account_ts = self.account_time.load(Ordering::Relaxed); + format!("{}_{}_{}", config.id, config.account.to_hex(), account_ts) + } + + /// Get Slatepack address to receive txs at transport. + pub fn slatepack_address(&self) -> Option { + let r_address = self.slatepack_address.read(); + if r_address.is_some() { + let addr = r_address.clone(); + return addr; + } + None + } + + /// Get wallet config. + pub fn get_config(&self) -> WalletConfig { + self.config.read().clone() + } + + /// Change wallet name. + pub fn change_name(&self, name: String) { + let mut w_config = self.config.write(); + w_config.name = name; + w_config.save(); + } + + /// Check if Dandelion usage is needed to post transactions. + pub fn can_use_dandelion(&self) -> bool { + let r_config = self.config.read(); + r_config.use_dandelion.unwrap_or(true) + } + + /// Update usage of Dandelion to post transactions. + pub fn update_use_dandelion(&self, use_dandelion: bool) { + let mut w_config = self.config.write(); + w_config.use_dandelion = Some(use_dandelion); + w_config.save(); + } + + /// Update minimal amount of confirmations. + pub fn update_min_confirmations(&self, min_confirmations: u64) { + let mut w_config = self.config.write(); + w_config.min_confirmations = min_confirmations; + w_config.save(); + } + + /// Get transaction broadcasting delay in blocks. + pub fn broadcasting_delay(&self) -> u64 { + let r_config = self.config.read(); + r_config + .tx_broadcast_timeout + .unwrap_or(WalletConfig::BROADCASTING_TIMEOUT_DEFAULT) + } + + /// Update transaction broadcasting delay in blocks. + pub fn update_broadcasting_delay(&self, delay: u64) { + let mut w_config = self.config.write(); + w_config.tx_broadcast_timeout = Some(delay); + w_config.save(); + } + + /// Update external connection identifier. + pub fn update_connection(&self, conn: &ConnectionMethod) { + let mut w_config = self.config.write(); + w_config.ext_conn_id = match conn { + ConnectionMethod::Integrated => None, + ConnectionMethod::External(id, _) => Some(id.clone()), + }; + w_config.save(); + } + + /// Get external connection URL applied to [`WalletInstance`] + /// after wallet opening if sync is running or get it from config. + pub fn get_current_connection(&self) -> ConnectionMethod { + if self.sync_thread.read().is_some() { + let r_conn = self.connection.read(); + r_conn.clone() + } else { + let config = self.get_config(); + config.connection() + } + } + + /// Check if wallet is open. + pub fn is_open(&self) -> bool { + self.is_open.load(Ordering::Relaxed) + } + + /// Check if wallet is closing. + pub fn is_closing(&self) -> bool { + self.closing.load(Ordering::Relaxed) + } + + /// Close the wallet. + pub fn close(&self) { + let has_instance = { + let r_inst = self.instance.read(); + r_inst.is_some() + }; + if !self.is_open() || !has_instance { + return; + } + // Stop repairing. + if self.is_repairing() { + self.repair_needed.store(false, Ordering::Relaxed); + } + // Close wallet at separate thread. + let wallet_close = self.clone(); + let conn = wallet_close.connection.clone(); + thread::spawn(move || { + wallet_close.closing.store(true, Ordering::Relaxed); + // Wait common operations to finish. + while wallet_close.message_opening() + || wallet_close.send_creating() + || wallet_close.invoice_creating() + { + thread::sleep(Duration::from_millis(300)); + } + // Stop running API server. + let api_server_exists = { wallet_close.foreign_api_server.read().is_some() }; + if api_server_exists { + let mut w_api_server = wallet_close.foreign_api_server.write(); + w_api_server.as_mut().unwrap().0.stop(); + *w_api_server = None; + } + // Stop nostr service. + { + let mut w_nostr = wallet_close.nostr.write(); + if let Some(service) = w_nostr.take() { + service.stop(); + } + } + // Close the wallet. + let r_inst = wallet_close.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + Self::close_wallet(&instance); + wallet_close.closing.store(false, Ordering::Relaxed); + wallet_close.is_open.store(false, Ordering::Relaxed); + // Setup current connection. + { + let mut w_conn = conn.write(); + *w_conn = wallet_close.get_config().connection(); + } + wallet_close.from_node.store(false, Ordering::Relaxed); + // Start sync to exit from thread. + wallet_close.sync(); + }); + } + + /// Close wallet for provided [`WalletInstance`]. + fn close_wallet(instance: &WalletInstance) { + let mut wallet_lock = instance.lock(); + let lc = wallet_lock.lc_provider().unwrap(); + let _ = lc.close_wallet(None); + } + + /// Set wallet reopen status. + pub fn set_reopen(&self, reopen: bool) { + self.reopen.store(reopen, Ordering::Relaxed); + } + + /// Check if wallet reopen is needed. + pub fn reopen_needed(&self) -> bool { + self.reopen.load(Ordering::Relaxed) + } + + /// Get wallet info synchronization progress. + pub fn info_sync_progress(&self) -> u8 { + self.info_sync_progress.load(Ordering::Relaxed) + } + + /// Check if wallet had an error on synchronization. + pub fn sync_error(&self) -> bool { + self.sync_error.load(Ordering::Relaxed) + } + + /// Set an error for wallet on synchronization. + pub fn set_sync_error(&self, error: bool) { + self.sync_error.store(error, Ordering::Relaxed); + } + + /// Check if wallet was synced from node after opening. + pub fn synced_from_node(&self) -> bool { + self.from_node.load(Ordering::Relaxed) + } + + /// Get current wallet synchronization attempts before setting an error. + fn get_sync_attempts(&self) -> u8 { + self.sync_attempts.load(Ordering::Relaxed) + } + + /// Increment wallet synchronization attempts before setting an error. + fn increment_sync_attempts(&self) { + let mut attempts = self.get_sync_attempts(); + attempts += 1; + self.sync_attempts.store(attempts, Ordering::Relaxed); + } + + /// Reset wallet synchronization attempts. + fn reset_sync_attempts(&self) { + self.sync_attempts.store(0, Ordering::Relaxed); + } + + /// Select transaction by slate id. + fn retrieve_tx_by_id(&self, id: Option, slate_id: Option) -> Option { + let r_inst = self.instance.as_ref().read(); + let inst = r_inst.clone().unwrap(); + let mask = self.keychain_mask(); + if let Ok((_, txs)) = retrieve_txs(inst, mask.as_ref(), &None, false, id, slate_id, None) { + if !txs.is_empty() { + return Some(txs.get(0).unwrap().clone()); + } + } + None + } + + /// Select transactions with provided limit. + fn retrieve_txs(&self, limit: u32) -> Result, Error> { + let r_inst = self.instance.as_ref().read(); + let inst = r_inst.clone().unwrap(); + let mut wallet_lock = inst.lock(); + let lc = wallet_lock.lc_provider()?; + let w = lc.wallet_inst()?; + let parent_key_id = w.parent_key_id(); + // Retrieve txs from database. + let mut txs: Vec = w + .tx_log_iter()? + .filter(|tx| tx.is_ok()) + .map(|tx| tx.unwrap()) + .filter(|tx_entry| tx_entry.parent_key_id == parent_key_id) + // Filter transactions to not show txs without slate (usually unspent outputs). + .filter(|tx| { + tx.tx_slate_id.is_some() || (tx.tx_slate_id.is_none() && tx.payment_proof.is_some()) + }) + .filter(|tx_entry| { + if tx_entry.tx_type == TxLogEntryType::TxSent + || tx_entry.tx_type == TxLogEntryType::TxSentCancelled + { + BigInt::from(tx_entry.amount_debited) - BigInt::from(tx_entry.amount_credited) + >= BigInt::from(1) + } else { + BigInt::from(tx_entry.amount_credited) - BigInt::from(tx_entry.amount_debited) + >= BigInt::from(1) + } + }) + .collect(); + // Sort txs by creation date (newest first); sort_by_key is stable so the + // follow-up sort keeps this ordering within each group. + txs.sort_by_key(|tx| -tx.creation_ts.timestamp()); + // Then float unconfirmed txs to the top. + txs.sort_by_key(|tx| { + tx.confirmed + || tx.tx_type == TxLogEntryType::TxReceivedCancelled + || tx.tx_type == TxLogEntryType::TxSentCancelled + || tx.tx_type == TxLogEntryType::TxReverted + }); + // Apply limit. + txs.truncate(limit as usize); + Ok(txs) + } + + /// Delete txs with 0 amount. + fn clear_empty_txs(&self) -> Result<(), Error> { + let txs: Vec = { + let r_inst = self.instance.as_ref().read(); + let inst = r_inst.clone().unwrap(); + let mut wallet_lock = inst.lock(); + let lc = wallet_lock.lc_provider()?; + let w = lc.wallet_inst()?; + let parent_key_id = w.parent_key_id(); + // Retrieve txs from database. + w.tx_log_iter()? + .filter(|tx| tx.is_ok()) + .map(|tx| tx.unwrap()) + .filter(|tx_entry| tx_entry.parent_key_id == parent_key_id) + .filter(|tx_entry| { + if tx_entry.tx_type == TxLogEntryType::TxSent + || tx_entry.tx_type == TxLogEntryType::TxSentCancelled + { + BigInt::from(tx_entry.amount_debited) + - BigInt::from(tx_entry.amount_credited) + == BigInt::from(0) + } else if tx_entry.tx_type == TxLogEntryType::TxReceived + || tx_entry.tx_type == TxLogEntryType::TxReceivedCancelled + { + BigInt::from(tx_entry.amount_credited) + - BigInt::from(tx_entry.amount_debited) + == BigInt::from(0) + } else { + false + } + }) + .collect() + }; + for t in &txs { + self.delete_tx(t.id)?; + } + Ok(()) + } + + /// Send a task to the wallet. + pub fn task(&self, task: WalletTask) { + let r_tasks = self.tasks_sender.read(); + if r_tasks.is_some() { + match task { + WalletTask::CalculateFee(_, _) => { + let calculating = self.fee_calculating.load(Ordering::Relaxed); + self.fee_calculating + .store(calculating + 1, Ordering::Relaxed); + } + _ => {} + } + let _ = r_tasks.as_ref().unwrap().send(task); + } + } + + /// Create account into wallet. + pub fn create_account(&self, label: &String) -> Result<(), Error> { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut api = Owner::new(instance, None); + controller::owner_single_use( + None, + self.keychain_mask().as_ref(), + Some(&mut api), + |api, m| { + let id = api.create_account_path(m, label)?; + if self.get_data().is_none() { + return Err(Error::GenericError("No wallet data".to_string())); + } + let current_height = self.get_data().unwrap().info.last_confirmed_height; + if let Some(spendable_amount) = self.account_balance(current_height, api, m) { + let mut w_data = self.accounts.write(); + w_data.push(WalletAccount { + spendable_amount, + label: label.clone(), + path: id.to_bip_32_string(), + }); + w_data.sort_by_key(|w| w.label != label.clone()); + } + Ok(()) + }, + ) + } + + /// Set active account from provided label. + pub fn set_active_account(&self, label: &String) -> Result<(), Error> { + // Clear secret key for previous account. + { + let mut w_key = self.secret_key.write(); + *w_key = None; + } + + // Set new active account. + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut api = Owner::new(instance.clone(), None); + controller::owner_single_use( + None, + self.keychain_mask().as_ref(), + Some(&mut api), + |api, m| { + api.set_active_account(m, label)?; + self.account_time + .store(Utc::now().timestamp(), Ordering::Relaxed); + Ok(()) + }, + )?; + + // Update Slatepack address and secret key. + self.update_secret_key_addr()?; + + // Save account label into config. + let mut w_config = self.config.write(); + w_config.account = label.to_owned(); + w_config.save(); + + // Clear wallet info. + let mut w_data = self.data.write(); + *w_data = None; + + // Reset progress values. + self.info_sync_progress.store(0, Ordering::Relaxed); + + // Sync wallet data. + self.sync(); + Ok(()) + } + + /// Calculate current account balance. + fn account_balance( + &self, + current_height: u64, + o: &mut Owner, HTTPNodeClient, ExtKeychain>, + m: Option<&SecretKey>, + ) -> Option { + if let Ok(outputs) = o.retrieve_outputs(m, false, false, None) { + let mut spendable = 0; + let min_confirmations = self.get_config().min_confirmations; + for out_mapping in outputs.1 { + let out = out_mapping.output; + if out.status == grin_wallet_libwallet::OutputStatus::Unspent { + if !out.is_coinbase + || out.lock_height <= current_height + || out.num_confirmations(current_height) >= min_confirmations + { + spendable += out.value; + } + } + } + return Some(spendable); + } + None + } + + /// Get list of accounts for the wallet. + pub fn accounts(&self) -> Vec { + self.accounts.read().clone() + } + + /// Get wallet data. + pub fn get_data(&self) -> Option { + let r_data = self.data.read(); + r_data.clone() + } + + /// Load more transactions at list by increasing limit. + pub fn load_more_txs(&self) { + self.more_txs_loading.store(true, Ordering::Relaxed); + let wallet = self.clone(); + thread::spawn(move || { + // Wait when current sync will be finished. + while wallet.syncing() { + thread::sleep(Duration::from_secs(1)); + } + // Sync wallet data with new limit. + { + let mut w_data = wallet.data.write(); + if w_data.is_some() { + w_data.as_mut().unwrap().txs_limit += WalletData::TXS_LIMIT; + } + } + sync_wallet_data(&wallet, false); + wallet.more_txs_loading.store(false, Ordering::Relaxed); + }); + } + + /// Check if more transaction are loading. + pub fn more_txs_loading(&self) -> bool { + self.more_txs_loading.load(Ordering::Relaxed) + } + + /// Sync wallet data from node at sync thread or locally synchronously. + pub fn sync(&self) { + let thread_r = self.sync_thread.read(); + if let Some(thread) = thread_r.as_ref() { + thread.unpark(); + } + } + + /// Check if wallet is syncing. + pub fn syncing(&self) -> bool { + self.syncing.load(Ordering::Relaxed) + } + + /// Get running Foreign API server port. + pub fn foreign_api_port(&self) -> Option { + let r_api = self.foreign_api_server.read(); + if r_api.is_some() { + let api = r_api.as_ref().unwrap(); + return Some(api.1); + } + None + } + + /// Check if Slatepack message is opening. + pub fn message_opening(&self) -> bool { + self.message_opening.load(Ordering::Relaxed) + } + + /// Parse Slatepack message into [`Slate`]. + pub fn parse_slatepack( + &self, + text: &String, + ) -> Result<(Slate, Option), grin_wallet_controller::Error> { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut api = Owner::new(instance, None); + match parse_slatepack( + &mut api, + self.keychain_mask().as_ref(), + None, + Some(text.trim().to_string()), + ) { + Ok(s) => Ok(s), + Err(e) => Err(e), + } + } + + /// Create Slatepack message from provided slate. + fn create_slatepack_message( + &self, + slate: &Slate, + _: Option, + ) -> Result { + let mut message = "".to_string(); + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut api = Owner::new(instance, None); + controller::owner_single_use( + None, + self.keychain_mask().as_ref(), + Some(&mut api), + |api, m| { + // let recipients = match dest { + // Some(a) => vec![a], + // None => vec![], + // }; + message = api.create_slatepack_message(m, &slate, Some(0), vec![])?; + Ok(()) + }, + )?; + + // Write Slatepack message to file. + let slatepack_dir = self.get_config().get_slate_path(slate.id, &slate.state); + let mut output = File::create(slatepack_dir)?; + output.write_all(message.as_bytes())?; + output.sync_all()?; + Ok(message) + } + + /// Check if Slatepack file exists. + pub fn slatepack_exists(&self, slate: &Slate) -> bool { + let slatepack_path = self.get_config().get_slate_path(slate.id, &slate.state); + fs::exists(slatepack_path).unwrap_or(false) + } + + /// Read a stored Slatepack message text for the given slate id and state. + pub fn read_slatepack_text(&self, id: Uuid, state: &SlateState) -> Option { + let path = self.get_config().get_slate_path(id, state); + fs::read_to_string(path).ok() + } + + /// Check if the wallet has a transaction for the given slate id. + pub fn has_tx_for_slate(&self, slate_id: &Uuid) -> bool { + self.retrieve_tx_by_id(None, Some(*slate_id)).is_some() + } + + /// Manual slatepack send (the GRIM-native flow, exposed for the advanced + /// Settings page): build a Standard1 payment of `amount` nanogrin to an + /// optional recipient slatepack address, locking the inputs, and return the + /// armored slatepack text to hand to the recipient out-of-band. + pub fn manual_send_slatepack( + &self, + amount: u64, + dest: Option, + ) -> Result { + let dest = match dest { + Some(a) => Some( + SlatepackAddress::try_from(a.trim()) + .map_err(|_| Error::GenericError("Invalid recipient address".to_string()))?, + ), + None => None, + }; + let slate = self.send(amount, dest)?; + self.read_slatepack_text(slate.id, &slate.state) + .ok_or_else(|| Error::GenericError("Slatepack message missing".to_string())) + } + + /// Manual slatepack ingest mirroring [`WalletTask::OpenMessage`]'s routing: + /// receiving a Standard1 or paying an Invoice1 is node-free, so it runs inline + /// and returns the reply slatepack to send back; finalizing a returned slate + /// (Standard2/Invoice2) posts to the node, so it's handed to the worker. + pub fn manual_process_slatepack(&self, text: &String) -> Result { + let (slate, dest) = self + .parse_slatepack(text) + .map_err(|e| Error::GenericError(e.to_string()))?; + match slate.state { + SlateState::Standard1 => { + let reply = self.receive(&slate, dest)?; + let text = self + .read_slatepack_text(reply.id, &reply.state) + .ok_or_else(|| Error::GenericError("Reply slatepack missing".to_string()))?; + Ok(ManualSlatepackOutcome::Response(text)) + } + SlateState::Invoice1 => { + let reply = self.pay(&slate)?; + let text = self + .read_slatepack_text(reply.id, &reply.state) + .ok_or_else(|| Error::GenericError("Reply slatepack missing".to_string()))?; + Ok(ManualSlatepackOutcome::Response(text)) + } + SlateState::Standard2 | SlateState::Invoice2 => { + // Finalize + post hits the node; let the worker handle it (GRIM's + // OpenMessage does exactly this routing). + self.task(WalletTask::OpenMessage(text.clone())); + Ok(ManualSlatepackOutcome::Finalizing) + } + _ => Err(Error::GenericError( + "This slatepack is already complete or isn't one Goblin can continue.".to_string(), + )), + } + } + + /// Guarded nostr ingest: receive an incoming Standard1 payment and return + /// the S2 reply slate with its slatepack text. Receiving only creates an + /// output and signs — it never spends funds. + pub fn nostr_receive(&self, slate: &Slate) -> Result<(Slate, String), Error> { + let reply = self.receive(slate, None)?; + let text = self + .read_slatepack_text(reply.id, &reply.state) + .ok_or_else(|| Error::GenericError("response slatepack missing".to_string()))?; + Ok((reply, text)) + } + + /// Guarded nostr ingest: finalize and post a matching S2/I2 reply. + /// Caller (ingest policy) has already verified the counterparty. + /// + /// Returns `Ok(true)` when the reply was finalized + posted, `Ok(false)` when + /// the local tx had been cancelled out-of-band (manual "Cancel payment", the + /// generic tx-list cancel, or 24h expiry) so the reply was intentionally + /// skipped — the caller records it as handled, NOT retried, and never + /// re-posts a payment the sender already reclaimed. + pub fn nostr_finalize_post(&self, slate: &Slate) -> Result { + // Serialize against a concurrent manual cancel of the same payment: hold + // the lock across the check + finalize + post so a cancel can't reclaim + // the outputs while we post them on-chain (and vice-versa). The guard is + // kept alive for the whole function via `_svc`/`_lock`. + let _svc = self.nostr_service(); + let _lock = _svc.as_ref().map(|s| s.lock_finalize()); + let tx = self + .retrieve_tx_by_id(None, Some(slate.id)) + .ok_or_else(|| Error::GenericError("transaction not found".to_string()))?; + if matches!( + tx.tx_type, + TxLogEntryType::TxSentCancelled | TxLogEntryType::TxReceivedCancelled + ) { + return Ok(false); + } + // Also honour a cancel that marked the meta but whose grin cancel hasn't + // committed yet (the cancel handler marks the meta first, under this lock). + if let Some(svc) = &_svc { + if svc + .store + .tx_meta(&slate.id.to_string()) + .map(|m| m.status == crate::nostr::NostrSendStatus::Cancelled) + .unwrap_or(false) + { + return Ok(false); + } + } + // A prior attempt may have finalized but then failed to post (a transient + // node outage). Re-finalizing errors on the already-finalized tx, so when + // the finalized (Standard3) slatepack is already on disk we parse and + // re-post it instead of finalizing again — a post failure then recovers on + // the next retry rather than getting permanently stuck. + let finalized = match self.read_slatepack_text(slate.id, &SlateState::Standard3) { + Some(text) => { + self.parse_slatepack(&text) + .map_err(|e| Error::GenericError(format!("reload finalized slate: {e}")))? + .0 + } + None => self.finalize(slate, tx.id)?, + }; + self.post(&finalized, Some(tx.id))?; + Ok(true) + } + + /// Pay an APPROVED payment request (Invoice1). Only ever called from the + /// explicit user approval task — never from the ingest pipeline. + pub fn nostr_pay(&self, slate: &Slate) -> Result<(Slate, String), Error> { + let reply = self.pay(slate)?; + let text = self + .read_slatepack_text(reply.id, &reply.state) + .ok_or_else(|| Error::GenericError("response slatepack missing".to_string()))?; + Ok((reply, text)) + } + + /// Get possible state from tx type. + pub fn get_slate_state(&self, slate_id: Uuid, tx_type: &TxLogEntryType) -> SlateState { + let mut slate = Slate::blank(1, false); + slate.id = slate_id; + slate.state = match tx_type { + TxLogEntryType::TxReceived => SlateState::Invoice3, + _ => SlateState::Standard3, + }; + // Transaction was finalized. + if self.slatepack_exists(&slate) { + slate.state + } else { + slate.state = match tx_type { + TxLogEntryType::TxReceived => SlateState::Standard2, + _ => SlateState::Invoice2, + }; + // Transaction signed to be finalized. + if self.slatepack_exists(&slate) { + slate.state + } else { + // Transaction just was created. + slate.state = match tx_type { + TxLogEntryType::TxReceived => SlateState::Invoice1, + _ => SlateState::Standard1, + }; + if self.slatepack_exists(&slate) { + slate.state + } else { + SlateState::Unknown + } + } + } + } + + /// Calculate transaction fee for provided amount. + fn calculate_fee(&self, a: u64) -> Result { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut w_lock = instance.lock(); + let w = w_lock.lc_provider()?.wallet_inst()?; + let config = self.get_config(); + let args = InitTxArgs { + src_acct_name: Some(config.account.clone()), + amount: a, + minimum_confirmations: config.min_confirmations, + num_change_outputs: 1, + selection_strategy_is_use_all: false, + estimate_only: Some(true), + ..Default::default() + }; + let res = init_send_tx(w, self.keychain_mask().as_ref(), args, false); + match res { + Ok(slate) => Ok(slate.fee_fields.fee()), + Err(e) => match e { + Error::NotEnoughFunds { + available, needed, .. + } => Ok(needed - available), + e => Err(e), + }, + } + } + + /// Check if transaction fee is calculating. + pub fn fee_calculating(&self) -> bool { + self.fee_calculating.load(Ordering::Relaxed) > 0 + } + + /// Last calculated network fee for `amount`, if one is cached. Returns + /// `None` until a `CalculateFee` task for that exact amount has completed. + pub fn calculated_fee(&self, amount: u64) -> Option { + self.last_fee + .read() + .and_then(|(a, f)| if a == amount { Some(f) } else { None }) + } + + /// Initialize a transaction to send amount. + fn send(&self, a: u64, dest: Option) -> Result { + let config = self.get_config(); + let args = InitTxArgs { + payment_proof_recipient_address: dest.clone(), + src_acct_name: Some(config.account), + amount: a, + minimum_confirmations: config.min_confirmations, + num_change_outputs: 1, + selection_strategy_is_use_all: false, + ..Default::default() + }; + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut api = Owner::new(instance, None); + let mut slate = None; + let keychain_mask = self.keychain_mask(); + controller::owner_single_use(None, keychain_mask.as_ref(), Some(&mut api), |api, m| { + let s = api.init_send_tx(m, args)?; + // Create Slatepack message response. + let _ = self.create_slatepack_message(&s, dest)?; + // Lock outputs to for this transaction. + api.tx_lock_outputs(m, &s)?; + slate = Some(s); + Ok(()) + })?; + if let Some(slate) = slate { + Ok(slate) + } else { + Err(Error::GenericError("slate was not created".to_string())) + } + } + + /// Check if request to send funds is creating. + pub fn send_creating(&self) -> bool { + self.send_creating.load(Ordering::Relaxed) + } + + /// Initialize an invoice transaction to receive amount, return request for funds sender. + fn issue_invoice(&self, amount: u64) -> Result { + let args = IssueInvoiceTxArgs { + dest_acct_name: None, + amount, + target_slate_version: None, + }; + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let api = Owner::new(instance, None); + let slate = api.issue_invoice_tx(self.keychain_mask().as_ref(), args)?; + + // Create Slatepack message response. + let _ = self.create_slatepack_message(&slate, None)?; + + Ok(slate) + } + + /// Handle message from the invoice issuer to send founds, return response for funds receiver. + fn pay(&self, slate: &Slate) -> Result { + let config = self.get_config(); + let args = InitTxArgs { + src_acct_name: None, + amount: slate.amount, + minimum_confirmations: config.min_confirmations, + selection_strategy_is_use_all: false, + ..Default::default() + }; + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let api = Owner::new(instance, None); + let slate = api.process_invoice_tx(self.keychain_mask().as_ref(), &slate, args)?; + api.tx_lock_outputs(self.keychain_mask().as_ref(), &slate)?; + + // Create Slatepack message response. + let _ = self.create_slatepack_message(&slate, None)?; + + Ok(slate) + } + + /// Check if request to receive funds is creating. + pub fn invoice_creating(&self) -> bool { + self.invoice_creating.load(Ordering::Relaxed) + } + + /// Create response to sender to receive funds. + fn receive(&self, slate: &Slate, dest: Option) -> Result { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let api = Owner::new(instance, None); + let mut slate = slate.clone(); + controller::foreign_single_use(api.wallet_inst.clone(), self.keychain_mask(), |api| { + slate = api.receive_tx(&slate, Some(self.get_config().account.as_str()), None)?; + Ok(()) + })?; + + // Create Slatepack message response. + let _ = self.create_slatepack_message(&slate, dest)?; + + Ok(slate) + } + + /// Finalize transaction from provided message as sender or invoice issuer. + fn finalize(&self, slate: &Slate, id: u32) -> Result { + self.on_tx_action(id, Some(WalletTxAction::Finalizing)); + + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let api = Owner::new(instance, None); + let mut slate = slate.clone(); + controller::foreign_single_use(api.wallet_inst.clone(), self.keychain_mask(), |api| { + slate = api.finalize_tx(&slate, false)?; + Ok(()) + })?; + + // Save Slatepack message to file. + let _ = self.create_slatepack_message(&slate, None)?; + + // Clear tx action. + self.on_tx_action(id, None); + + Ok(slate) + } + + /// Post transaction to blockchain. + fn post(&self, slate: &Slate, id: Option) -> Result<(), Error> { + if let Some(id) = id { + self.on_tx_action(id, Some(WalletTxAction::Posting)); + } + + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut api = Owner::new(instance, None); + controller::owner_single_use( + None, + self.keychain_mask().as_ref(), + Some(&mut api), + |api, m| { + api.post_tx(m, &slate, self.can_use_dandelion())?; + Ok(()) + }, + )?; + + // Clear tx action. + if let Some(id) = id { + self.on_tx_action(id, None); + } + Ok(()) + } + + /// Cancel transaction. + fn cancel(&self, id: u32) -> Result<(), Error> { + self.on_tx_action(id, Some(WalletTxAction::Cancelling)); + + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + cancel_tx( + instance, + self.keychain_mask().as_ref(), + &None, + Some(id), + None, + )?; + + // Clear tx action. + self.on_tx_action(id, None); + + Ok(()) + } + + /// Update transaction action status. + fn on_tx_action(&self, id: u32, action: Option) { + let mut w_data = self.data.write(); + if let Some(data) = w_data.as_mut() { + data.on_tx_action(id, action); + } + } + + /// Update transaction action error status. + fn on_tx_error(&self, id: u32, err: Option) { + let mut w_data = self.data.write(); + if let Some(data) = w_data.as_mut() { + data.on_tx_error(id, err); + } + } + + /// Save task result to consume later. + fn on_task_result(&self, tx: Option, task: &WalletTask) { + let mut w_res = self.task_result.write(); + let id = if let Some(t) = tx { Some(t.id) } else { None }; + *w_res = Some((id, task.clone())); + } + + /// Consume result of successful task. + pub fn consume_task_result(&self) -> Option<(Option, WalletTask)> { + let res = { + let r_res = self.task_result.read(); + r_res.clone() + }; + // Clear result for task. + let mut w_res = self.task_result.write(); + *w_res = None; + res + } + + /// Get possible transaction confirmation height. + fn tx_height(&self, tx: &WalletTx) -> Result, Error> { + let mut tx_height = None; + if tx.data.confirmed && tx.data.kernel_excess.is_some() { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut w_lock = instance.lock(); + let w = w_lock.lc_provider()?.wallet_inst()?; + if let Ok(res) = w.w2n_client().get_kernel( + tx.data.kernel_excess.as_ref().unwrap(), + tx.data.kernel_lookup_min_height, + None, + ) { + tx_height = Some(match res { + None => 0, + Some((_, h, _)) => h, + }); + } + } else if tx.broadcasting() { + tx_height = match self.get_data() { + None => None, + Some(data) => Some(data.info.last_confirmed_height), + }; + } + Ok(tx_height) + } + + /// Get stored transaction Slate. + fn get_tx_slate(&self, tx_id: u32) -> Option { + if let Some(tx) = self.retrieve_tx_by_id(Some(tx_id), None) { + if let Some(slate_id) = tx.tx_slate_id { + let slate_state = self.get_slate_state(slate_id, &tx.tx_type); + let slatepack_path = self.get_config().get_slate_path(slate_id, &slate_state); + let msg = fs::read_to_string(slatepack_path).unwrap_or("".to_string()); + if let Ok((slate, _)) = self.parse_slatepack(&msg) { + return Some(slate); + } + } + } + None + } + + /// Delete transaction from database. + fn delete_tx(&self, id: u32) -> Result<(), Error> { + self.on_tx_action(id, Some(WalletTxAction::Deleting)); + + let slate = self.get_tx_slate(id); + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let keychain_mask = self.keychain_mask(); + let mut wallet_lock = instance.lock(); + let lc = wallet_lock.lc_provider()?; + let w = lc.wallet_inst()?; + let parent_key = w.parent_key_id(); + let mut batch = w.batch(keychain_mask.as_ref())?; + batch.delete_tx_log_entry(id, &parent_key)?; + batch.commit()?; + + // Delete transaction files. + if let Some(s) = slate { + let slatepack_path = self.get_config().get_slate_path(s.id, &s.state); + fs::remove_file(&slatepack_path).unwrap_or_default(); + let path = path::Path::new(&self.get_config().get_data_path()) + .join("saved_txs") + .join(format!("{}.grintx", s.id)); + fs::remove_file(&path).unwrap_or_default(); + } + Ok(()) + } + + /// Change wallet password. + pub fn change_password(&self, old: String, new: String) -> Result<(), Error> { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut wallet_lock = instance.lock(); + let lc = wallet_lock.lc_provider()?; + lc.change_password(None, ZeroingString::from(old), ZeroingString::from(new)) + } + + /// Initiate wallet repair by scanning its outputs. + pub fn repair(&self) { + self.repair_needed.store(true, Ordering::Relaxed); + self.sync(); + } + + /// Check if wallet is repairing. + pub fn is_repairing(&self) -> bool { + self.repair_needed.load(Ordering::Relaxed) + } + + /// Get wallet repairing progress. + pub fn repairing_progress(&self) -> u8 { + self.repair_progress.load(Ordering::Relaxed) + } + + /// Change wallet data path, migrating all files to new directory. + pub fn change_data_path(&self, path: String) { + let wallet = self.clone(); + wallet.files_moving.store(true, Ordering::Relaxed); + // Close wallet if open. + if self.is_open() { + self.close(); + } + thread::spawn(move || { + // Wait wallet to be closed. + while wallet.is_open() || wallet.syncing() { + thread::sleep(Duration::from_millis(100)); + } + // Move wallet db files. + if let Some(old_path) = wallet.get_config().data_path { + let mut old = PathBuf::from(old_path.as_str()); + old.push(WalletConfig::DATA_DIR_NAME); + let mut new = PathBuf::from(path.as_str()); + new.push(WalletConfig::DATA_DIR_NAME); + if old.exists() { + fs::create_dir_all(&new).unwrap_or_default(); + if let Ok(_) = fs::rename(old.as_path(), new.as_path()) { + // Save new path to config. + let mut w_config = wallet.config.write(); + w_config.data_path = Some(path); + w_config.save(); + } + } + } + wallet.files_moving.store(false, Ordering::Relaxed); + // Mark wallet to reopen. + if !wallet.is_open() { + wallet.set_reopen(true); + } + }); + } + + /// Deleting wallet database files. + pub fn delete_db(&self) { + let wallet = self.clone(); + wallet.files_moving.store(true, Ordering::Relaxed); + // Close wallet if open. + if self.is_open() { + self.close(); + } + thread::spawn(move || { + // Wait wallet to be closed. + while wallet.is_open() || wallet.syncing() { + thread::sleep(Duration::from_millis(100)); + } + // Remove wallet db files. + let _ = fs::remove_dir_all(wallet.get_config().get_db_path()); + wallet.files_moving.store(false, Ordering::Relaxed); + // Mark wallet to repair. + wallet.repair(); + // Mark wallet to reopen. + if !wallet.is_open() { + wallet.set_reopen(true); + } + }); + } + + /// Check if data files are moving. + pub fn files_moving(&self) -> bool { + self.files_moving.load(Ordering::Relaxed) + } + + /// Retrieve payment proof. + pub fn get_payment_proof( + &self, + tx_id: Option, + slate_id: Option, + ) -> Result, Error> { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let key_mask = self.keychain_mask(); + let mut api = Owner::new(instance, None); + let mut proof = None; + controller::owner_single_use(None, key_mask.as_ref(), Some(&mut api), |api, m| { + let result = api.retrieve_payment_proof(m, false, tx_id, slate_id); + proof = match result { + Ok(p) => Some(p), + Err(e) => { + error!("retrieve_payment_proof error: {}", e); + None + } + }; + Ok(()) + })?; + Ok(proof) + } + + /// Verify payment proof. + fn verify_payment_proof(&self, proof: &PaymentProof) -> Result<(u32, bool, bool), Error> { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let keychain_mask = self.keychain_mask(); + let verify_res = verify_payment_proof(instance.clone(), keychain_mask.as_ref(), proof); + let res = match verify_res { + Ok((send, rec)) => { + // Update proof at local database for valid proof. + if send || rec { + let mut wallet_lock = instance.lock(); + let lc = wallet_lock.lc_provider()?; + let w = lc.wallet_inst()?; + // Find wallet transaction to update or create. + let txs = w + .tx_log_iter()? + .filter(|tx| tx.is_ok()) + .map(|tx| tx.unwrap()) + .filter(|entry| { + if let Some(excess) = entry.kernel_excess { + return excess == proof.excess; + } + false + }) + .collect::>(); + if let Some(tx) = txs.get(0) { + let mut tx = tx.clone(); + let mut batch = w.batch(keychain_mask.as_ref())?; + let parent_key = &tx.parent_key_id; + tx.payment_proof = Some(StoredProofInfo { + receiver_address: proof.recipient_address.pub_key, + receiver_signature: Some(proof.recipient_sig), + sender_address_path: 0, + sender_address: proof.sender_address.pub_key, + sender_signature: Some(proof.sender_sig), + }); + batch.save_tx_log_entry(tx.clone(), &parent_key)?; + batch.commit()?; + Ok((tx.id, send, rec)) + } else { + let parent_key = w.parent_key_id(); + let mut batch = w.batch(keychain_mask.as_ref())?; + let log_id = batch.next_tx_log_id(&parent_key)?; + let log_type = TxLogEntryType::TxSent; + let mut tx = TxLogEntry::new(parent_key.clone(), log_type, log_id); + tx.amount_debited = proof.amount; + tx.kernel_excess = Some(proof.excess); + tx.tx_type = TxLogEntryType::TxSent; + tx.confirmed = true; + tx.payment_proof = Some(StoredProofInfo { + receiver_address: proof.recipient_address.pub_key, + receiver_signature: Some(proof.recipient_sig), + sender_address_path: 0, + sender_address: proof.sender_address.pub_key, + sender_signature: Some(proof.sender_sig), + }); + batch.save_tx_log_entry(tx.clone(), &parent_key)?; + batch.commit()?; + Ok((tx.id, send, rec)) + } + } else { + Ok((0, send, rec)) + } + } + Err(e) => Err(e), + }; + // Sync wallet data on success. + if res.is_ok() { + sync_wallet_data(self, false); + } + res + } + + /// Check if payment proof is verifying. + pub fn payment_proof_verifying(&self) -> bool { + self.proof_verifying.load(Ordering::Relaxed) + } + + /// Get recovery phrase. + pub fn get_recovery(&self, password: String) -> Result { + let r_inst = self.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut wallet_lock = instance.lock(); + let lc = wallet_lock.lc_provider()?; + lc.get_mnemonic(None, ZeroingString::from(password)) + } + + /// Close the wallet, delete its files and mark it as deleted. + pub fn delete_wallet(&self) { + if self.is_open() { + self.close(); + } + // Mark wallet as deleted. + let wallet_delete = self.clone(); + wallet_delete.deleted.store(true, Ordering::Relaxed); + + thread::spawn(move || { + // Wait wallet to be closed. + if wallet_delete.is_open() { + thread::sleep(Duration::from_millis(100)); + } + // Remove wallet files. + let _ = fs::remove_dir_all(wallet_delete.get_config().get_wallet_path()); + // Mark wallet as deleted. + wallet_delete.deleted.store(true, Ordering::Relaxed); + // Start sync to close thread. + wallet_delete.sync(); + }); + } + + /// Check if wallet was deleted to remove it from list. + pub fn is_deleted(&self) -> bool { + self.deleted.load(Ordering::Relaxed) + } +} + +/// Delay in seconds to sync [`WalletData`] (60 seconds as average block time). +const SYNC_DELAY: Duration = Duration::from_millis(60 * 1000); +/// Delay in seconds for sync thread to wait before start of new attempt. +const ATTEMPT_DELAY: Duration = Duration::from_millis(3 * 1000); +/// Number of attempts to sync [`WalletData`] before setting an error. +const SYNC_ATTEMPTS: u8 = 10; + +/// Launch thread to sync wallet data from node. +fn start_sync(wallet: Wallet) -> Thread { + // Start tasks thread. + let (tx, rx) = mpsc::channel(); + { + let mut w_tasks = wallet.tasks_sender.write(); + *w_tasks = Some(tx); + } + let wallet_thread = wallet.clone(); + thread::spawn(move || { + loop { + let wallet_task = wallet_thread.clone(); + if let Ok(task) = rx.recv() { + thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + handle_task(&wallet_task, task).await; + }); + }); + } + if wallet_thread.is_closing() || !wallet_thread.is_open() { + break; + } + } + }); + + // Reset progress values. + wallet.info_sync_progress.store(0, Ordering::Relaxed); + wallet.repair_progress.store(0, Ordering::Relaxed); + + // To call on sync thread stop. + let on_thread_stop = |wallet: Wallet| { + // Clear thread instance. + let mut thread_w = wallet.sync_thread.write(); + *thread_w = None; + + // Clear wallet info. + let mut w_data = wallet.data.write(); + *w_data = None; + + // Clear syncing status. + wallet.syncing.store(false, Ordering::Relaxed); + }; + + thread::spawn(move || { + loop { + // Set syncing status. + wallet.syncing.store(true, Ordering::Relaxed); + + // Close wallet on chain type change. + if wallet.get_config().chain_type != AppConfig::chain_type() { + wallet.close(); + } + + // Stop syncing if wallet was closed. + if !wallet.is_open() || wallet.is_closing() { + on_thread_stop(wallet); + return; + } + + // Start the nostr payment-messaging service the moment the wallet is + // open — BEFORE (and independent of) the grin node sync. Previously + // this lived deep in the sync body behind `!sync_error` and the node + // checks, so the Nym/relay connection could wait up to a full + // SYNC_DELAY (60s) — or never start while the node errored — leaving + // the profile stuck on "Connecting…". Idempotent. + if let Some(service) = wallet.nostr_service() { + service.start(wallet.clone()); + // Auto-cancel/expire stale pending transactions (frees outputs + // locked by stale sends). + service.expire_stale(&wallet); + } + + // Check integrated node state. + if wallet.get_current_connection() == ConnectionMethod::Integrated { + let not_enabled = !Node::is_running() || Node::is_stopping(); + if not_enabled { + // Reset loading progress. + wallet.info_sync_progress.store(0, Ordering::Relaxed); + } + // Set an error when integrated node is not enabled. + wallet.set_sync_error(not_enabled); + // Skip cycle when node sync is not finished. + if !Node::is_running() || Node::get_sync_status() != Some(SyncStatus::NoSync) { + thread::park_timeout(ATTEMPT_DELAY); + continue; + } + } + + // Scan outputs if repair is needed or sync data if there is no error. + if !wallet.sync_error() { + if wallet.is_repairing() { + repair_wallet(&wallet); + // Stop sync if wallet was closed. + if !wallet.is_open() || wallet.is_closing() { + on_thread_stop(wallet); + return; + } + } + // Retrieve data from local database if current data is empty. + if wallet.get_data().is_none() { + sync_wallet_data(&wallet, false); + } + + if wallet.is_open() && !wallet.is_closing() { + // Start Foreign API listener if not running. + let api_server_running = { wallet.foreign_api_server.read().is_some() }; + if !api_server_running { + match start_api_server(&wallet) { + Ok(api_server) => { + let mut api_server_w = wallet.foreign_api_server.write(); + *api_server_w = Some(api_server); + } + Err(_) => {} + } + } + } + + // Sync wallet from node. + sync_wallet_data(&wallet, true); + } + + // Stop sync if wallet was closed. + if !wallet.is_open() || wallet.is_closing() { + on_thread_stop(wallet); + return; + } + + // Setup flag to check if sync was failed. + let failed_sync = wallet.sync_error() || wallet.get_sync_attempts() != 0; + + // Clear syncing status. + if !failed_sync { + wallet.syncing.store(false, Ordering::Relaxed); + } + + // Repeat after default or attempt delay if synchronization was not successful. + let delay = if failed_sync { + ATTEMPT_DELAY + } else { + SYNC_DELAY + }; + thread::park_timeout(delay); + } + }) + .thread() + .clone() +} + +/// Map a wallet error to a short, user-facing reason for the failure screen so +/// "Couldn't send" actually explains itself — most often locked/unconfirmed +/// funds after a recent payment. +fn friendly_send_error(e: &Error) -> String { + let s = format!("{e:?}"); + if s.contains("NotEnoughFunds") { + "Not enough spendable grin — coins from a recent payment may still be confirming (about 10 min). Try again once it clears.".to_string() + } else { + format!("Couldn't complete the payment: {e}") + } +} + +/// Handle wallet task. +async fn handle_task(w: &Wallet, t: WalletTask) { + match &t { + WalletTask::OpenMessage(m) => { + if !w.is_open() || m.is_empty() { + return; + } + let w = w.clone(); + let msg = m.clone(); + w.message_opening.store(true, Ordering::Relaxed); + if let Ok((s, dest)) = w.parse_slatepack(&msg) { + let tx = w.retrieve_tx_by_id(None, Some(s.id)); + // Check if message already exists. + let exists = { + let mut exists = w.slatepack_exists(&s); + if !exists + && (s.state == SlateState::Invoice2 || s.state == SlateState::Standard2) + { + let mut slate = s.clone(); + slate.state = if s.state == SlateState::Standard2 { + SlateState::Standard3 + } else { + SlateState::Invoice3 + }; + exists = w.slatepack_exists(&slate); + } + exists + }; + if exists { + w.on_task_result(tx, &t); + w.message_opening.store(false, Ordering::Relaxed); + return; + } + // Create response or finalize. + match s.state { + SlateState::Standard1 | SlateState::Invoice1 => { + if s.state != SlateState::Standard1 { + if let Ok(_) = w.pay(&s) { + sync_wallet_data(&w, false); + let tx = w.retrieve_tx_by_id(None, Some(s.id)); + w.on_task_result(tx, &t); + } + } else { + if let Ok(_) = w.receive(&s, dest) { + sync_wallet_data(&w, false); + let tx = w.retrieve_tx_by_id(None, Some(s.id)); + w.on_task_result(tx, &t); + } + } + } + SlateState::Standard2 | SlateState::Invoice2 => { + if let Some(tx) = tx { + match w.finalize(&s, tx.id) { + Ok(s) => match w.post(&s, Some(tx.id)) { + Ok(_) => { + sync_wallet_data(&w, false); + } + Err(e) => { + error!("message tx post error: {:?}", e); + w.on_tx_error(tx.id, Some(e)); + } + }, + Err(e) => { + error!("message tx finalize error: {:?}", e); + w.task(WalletTask::Cancel(tx.id)); + } + } + } + } + _ => {} + }; + } + w.message_opening.store(false, Ordering::Relaxed); + } + WalletTask::CalculateFee(a, _) => { + // Wait if there are no more fee tasks or handle next input value. + let calculating = w.fee_calculating.load(Ordering::Relaxed); + if calculating == 1 { + async_std::task::sleep(Duration::from_millis(100)).await; + let calculating = w.fee_calculating.load(Ordering::Relaxed); + if calculating > 1 { + w.fee_calculating.store(calculating - 1, Ordering::Relaxed); + return; + } + } else { + w.fee_calculating.store(calculating - 1, Ordering::Relaxed); + return; + } + // Calculate fee for provided amount. + if let Ok(fee) = w.calculate_fee(*a) { + *w.last_fee.write() = Some((*a, fee)); + w.on_task_result(None, &WalletTask::CalculateFee(*a, fee)) + } + let calculating = w.fee_calculating.load(Ordering::Relaxed); + w.fee_calculating.store(calculating - 1, Ordering::Relaxed); + } + WalletTask::Send(a, r) => { + w.send_creating.store(true, Ordering::Relaxed); + if let Ok(s) = w.send(*a, r.clone()) { + sync_wallet_data(&w, false); + let tx = w.retrieve_tx_by_id(None, Some(s.id)); + if let Some(tx) = tx { + // Slatepack send: hand the response slate back to the UI. + // (Goblin's online payments go over nostr via NostrSend.) + w.on_task_result(Some(tx), &t); + } + } + w.send_creating.store(false, Ordering::Relaxed); + } + WalletTask::Receive(a) => { + w.invoice_creating.store(true, Ordering::Relaxed); + if let Ok(s) = w.issue_invoice(*a) { + sync_wallet_data(&w, false); + let tx = w.retrieve_tx_by_id(None, Some(s.id)); + if let Some(tx) = tx { + w.on_task_result(Some(tx), &t); + } + } + w.invoice_creating.store(false, Ordering::Relaxed); + } + WalletTask::Finalize(id) => { + if let Some(s) = w.get_tx_slate(*id) { + w.on_tx_error(*id, None); + match w.finalize(&s, *id) { + Ok(s) => match w.post(&s, Some(*id)) { + Ok(_) => { + sync_wallet_data(&w, false); + } + Err(e) => { + error!("tx finalize post error: {:?}", e); + w.on_tx_error(*id, Some(e)); + } + }, + Err(e) => { + error!("tx finalize error: {:?}", e); + w.task(WalletTask::Cancel(*id)); + } + } + } else { + error!("tx finalize: slate not found"); + w.task(WalletTask::Cancel(*id)); + } + } + WalletTask::Post(id) => { + if let Some(s) = w.get_tx_slate(*id) { + w.on_tx_error(*id, None); + // Cleanup broadcasting tx height. + let tx_height_store = TxHeightStore::new(w.get_config().get_extra_db_path()); + tx_height_store.delete_broadcasting_height(&id.to_string()); + let has_data = { + let r_data = w.data.read(); + r_data.is_some() + }; + if has_data { + let mut w_data = w.data.write(); + for tx in w_data.as_mut().unwrap().txs.as_mut().unwrap() { + if tx.data.id == *id { + tx.broadcasting_height = None; + break; + } + } + } + // Post transaction. + match w.post(&s, Some(*id)) { + Ok(_) => { + sync_wallet_data(&w, false); + } + Err(e) => { + error!("tx post error: {:?}", e); + w.on_tx_error(*id, Some(e)); + } + } + } else { + error!("tx post: slate not found"); + w.task(WalletTask::Cancel(*id)); + } + } + WalletTask::Cancel(id) => match w.cancel(*id) { + Ok(_) => { + sync_wallet_data(&w, false); + } + Err(e) => { + error!("tx cancel error: {:?}", e); + w.on_tx_error(*id, Some(e)); + } + }, + WalletTask::VerifyProof(p, _) => { + w.proof_verifying.store(true, Ordering::Relaxed); + let res = w.verify_payment_proof(p); + w.proof_verifying.store(false, Ordering::Relaxed); + w.on_task_result(None, &WalletTask::VerifyProof(p.clone(), Some(res))); + } + WalletTask::Delete(id) => match w.delete_tx(*id) { + Ok(_) => sync_wallet_data(&w, false), + Err(e) => { + error!("tx delete error: {:?}", e); + w.on_tx_error(*id, Some(e)); + } + }, + WalletTask::NostrSend(a, receiver, note, relay_hints) => { + let Some(service) = w.nostr_service() else { + error!("nostr send: service not available"); + return; + }; + w.send_creating.store(true, Ordering::Relaxed); + service.set_send_phase(crate::nostr::send_phase::WORKING); + match w.send(*a, None) { + Ok(s) => { + sync_wallet_data(&w, false); + let now = crate::nostr::unix_time(); + // Record intent BEFORE the network dispatch so a crash + // is recovered by the service reconcile pass. + service.store.save_tx_meta(&crate::nostr::TxNostrMeta { + ver: 1, + slate_id: s.id.to_string(), + npub: receiver.clone(), + direction: crate::nostr::NostrTxDirection::Sent, + note: note.clone().and_then(|n| crate::nostr::sanitize_note(&n)), + status: crate::nostr::NostrSendStatus::Created, + sent_event_id: None, + received_rumor_id: None, + created_at: now, + updated_at: now, + }); + let tx = w.retrieve_tx_by_id(None, Some(s.id)); + w.send_creating.store(false, Ordering::Relaxed); + if let Some(text) = w.read_slatepack_text(s.id, &s.state) { + match service + .send_payment_dm(receiver, &text, note.as_deref(), relay_hints) + .await + { + Ok(event_id) => { + if let Some(mut meta) = service.store.tx_meta(&s.id.to_string()) { + meta.status = crate::nostr::NostrSendStatus::AwaitingS2; + meta.sent_event_id = Some(event_id); + meta.updated_at = crate::nostr::unix_time(); + service.store.save_tx_meta(&meta); + } + // Record/refresh the contact so someone you PAY shows up + // under Suggested (sends used to create no contact — only + // incoming payments did — so a person you paid first never + // appeared). Create on first pay, then stamp last_paid_at. + let mut contact = + service.store.contact(receiver).unwrap_or_else(|| { + crate::nostr::Contact { + ver: 1, + npub: receiver.clone(), + petname: None, + nip05: None, + nip05_verified_at: None, + relays: relay_hints.clone(), + hue: crate::gui::views::goblin::data::hue_of(receiver) + as u8, + unknown: true, + added_at: crate::nostr::unix_time(), + last_paid_at: None, + blocked: false, + } + }); + contact.last_paid_at = Some(crate::nostr::unix_time()); + contact.unknown = false; + service.store.save_contact(&contact); + // Resolve the recipient's @username so activity + Suggested + // show their name, not a bare npub. + service.resolve_contact_identity(receiver); + service.set_send_phase(crate::nostr::send_phase::SENT); + } + Err(e) => { + error!("nostr send dispatch failed: {e}"); + service.store.update_tx_status( + &s.id.to_string(), + crate::nostr::NostrSendStatus::SendFailed, + ); + if let Some(tx) = &tx { + w.on_tx_error( + tx.id, + Some(Error::GenericError(format!( + "nostr dispatch failed: {e}" + ))), + ); + } + service.set_send_phase(crate::nostr::send_phase::FAILED); + } + } + } else { + // No slatepack text produced — treat as failure. + service.set_send_phase(crate::nostr::send_phase::FAILED); + } + w.on_task_result(tx, &t); + } + Err(e) => { + error!("nostr send error: {:?}", e); + w.send_creating.store(false, Ordering::Relaxed); + service.fail_send(friendly_send_error(&e)); + } + } + } + WalletTask::NostrRequest(a, receiver, note, relay_hints) => { + let Some(service) = w.nostr_service() else { + error!("nostr request: service not available"); + return; + }; + service.set_send_phase(crate::nostr::send_phase::WORKING); + // Respect the recipient's published opt-out (fail-open: only an + // explicit "not accepting" blocks; unknown/unreachable still sends). + if service.accepts_requests(receiver).await == Some(false) { + service.set_send_phase(crate::nostr::send_phase::REQUEST_BLOCKED); + return; + } + w.invoice_creating.store(true, Ordering::Relaxed); + // Issue a grin Invoice1 (receiver-built slate, amount baked in). This + // never spends — it only proposes a payment to the contact. + match w.issue_invoice(*a) { + Ok(s) => { + sync_wallet_data(&w, false); + let now = crate::nostr::unix_time(); + // Record intent BEFORE dispatch so a crash is recovered by the + // service reconcile pass (RequestedByUs/AwaitingI2). + service.store.save_tx_meta(&crate::nostr::TxNostrMeta { + ver: 1, + slate_id: s.id.to_string(), + npub: receiver.clone(), + direction: crate::nostr::NostrTxDirection::RequestedByUs, + note: note.clone().and_then(|n| crate::nostr::sanitize_note(&n)), + status: crate::nostr::NostrSendStatus::Created, + sent_event_id: None, + received_rumor_id: None, + created_at: now, + updated_at: now, + }); + let tx = w.retrieve_tx_by_id(None, Some(s.id)); + w.invoice_creating.store(false, Ordering::Relaxed); + if let Some(text) = w.read_slatepack_text(s.id, &s.state) { + match service + .send_payment_dm(receiver, &text, note.as_deref(), relay_hints) + .await + { + Ok(event_id) => { + if let Some(mut meta) = service.store.tx_meta(&s.id.to_string()) { + meta.status = crate::nostr::NostrSendStatus::AwaitingI2; + meta.sent_event_id = Some(event_id); + meta.updated_at = crate::nostr::unix_time(); + service.store.save_tx_meta(&meta); + } + if let Some(mut contact) = service.store.contact(receiver) { + contact.unknown = false; + service.store.save_contact(&contact); + } + service.resolve_contact_identity(receiver); + service.set_send_phase(crate::nostr::send_phase::SENT); + } + Err(e) => { + error!("nostr request dispatch failed: {e}"); + service.store.update_tx_status( + &s.id.to_string(), + crate::nostr::NostrSendStatus::SendFailed, + ); + service.set_send_phase(crate::nostr::send_phase::FAILED); + } + } + } else { + service.set_send_phase(crate::nostr::send_phase::FAILED); + } + w.on_task_result(tx, &t); + } + Err(e) => { + error!("nostr request error: {:?}", e); + w.invoice_creating.store(false, Ordering::Relaxed); + service.set_send_phase(crate::nostr::send_phase::FAILED); + } + } + } + WalletTask::NostrRepublishProfile => { + if let Some(service) = w.nostr_service() { + service.republish_identity().await; + } + } + WalletTask::NostrDeclineRequest(rumor_id) => { + let Some(service) = w.nostr_service() else { + return; + }; + let Some(mut request) = service.store.request(rumor_id) else { + error!("nostr decline: request not found"); + return; + }; + // Mark declined locally (idempotent) so the card stays gone, then tell + // the requester. Requests are messages; payments are final. + request.status = crate::nostr::RequestStatus::Declined; + service.store.save_request(&request); + if let Err(e) = service + .send_control_dm(&request.npub, &request.slate_id, &[]) + .await + { + error!("nostr decline: control dispatch failed: {e}"); + } + } + WalletTask::NostrCancelOutgoing(slate_id) => { + let Some(service) = w.nostr_service() else { + return; + }; + let Some(meta) = service.store.tx_meta(slate_id) else { + error!("nostr cancel: no metadata for slate {slate_id}"); + return; + }; + if meta.direction != crate::nostr::NostrTxDirection::RequestedByUs { + error!("nostr cancel: slate {slate_id} is not an outgoing request"); + return; + } + // Cancel the underlying grin invoice tx (an issued invoice locks no + // outputs, but cancelling keeps the wallet ledger tidy). + if let Some(tx_id) = w.get_data().and_then(|d| d.txs).and_then(|txs| { + txs.iter() + .find(|t| { + t.data.tx_slate_id.map(|u| u.to_string()).as_deref() + == Some(slate_id.as_str()) + }) + .map(|t| t.data.id) + }) { + if let Err(e) = w.cancel(tx_id) { + error!("nostr cancel: wallet cancel failed: {e}"); + } + } + service + .store + .update_tx_status(slate_id, crate::nostr::NostrSendStatus::Cancelled); + if let Err(e) = service.send_control_dm(&meta.npub, slate_id, &[]).await { + error!("nostr cancel: control dispatch failed: {e}"); + } + sync_wallet_data(&w, false); + } + WalletTask::NostrCancelSend(slate_id) => { + let Some(service) = w.nostr_service() else { + return; + }; + let Some(meta) = service.store.tx_meta(slate_id) else { + error!("nostr cancel send: no metadata for slate {slate_id}"); + return; + }; + if meta.direction != crate::nostr::NostrTxDirection::Sent { + error!("nostr cancel send: slate {slate_id} is not an outgoing send"); + return; + } + let Ok(uuid) = uuid::Uuid::parse_str(slate_id) else { + error!("nostr cancel send: bad slate id {slate_id}"); + return; + }; + // The critical section is serialized with `nostr_finalize_post` so a + // concurrent S2 can't post the payment while we reclaim its outputs. + let mut did_cancel = false; + { + let _lock = service.lock_finalize(); + // Re-read status UNDER the lock. If the payment already finalized in + // the race window, refuse and report it; if it's already cancelled, + // report success idempotently. + match service.store.tx_meta(slate_id).map(|m| m.status) { + Some(crate::nostr::NostrSendStatus::Finalized) => { + service.set_cancel_notice(crate::nostr::CancelOutcome::AlreadyCompleted); + return; + } + Some(crate::nostr::NostrSendStatus::Cancelled) => { + service.set_cancel_notice(crate::nostr::CancelOutcome::Cancelled); + return; + } + _ => {} + } + // Authoritative tx lookup (not the paginated display cache). If it's + // missing we must NOT claim success — nothing was reclaimed. + let Some(tx) = w.retrieve_tx_by_id(None, Some(uuid)) else { + error!("nostr cancel send: grin tx not found for slate {slate_id}"); + service.set_cancel_notice(crate::nostr::CancelOutcome::AlreadyCompleted); + return; + }; + if tx.confirmed { + service.set_cancel_notice(crate::nostr::CancelOutcome::AlreadyCompleted); + return; + } + if matches!( + tx.tx_type, + TxLogEntryType::TxSentCancelled | TxLogEntryType::TxReceivedCancelled + ) { + // Already cancelled at the grin layer — just reconcile the meta. + service + .store + .update_tx_status(slate_id, crate::nostr::NostrSendStatus::Cancelled); + service.set_cancel_notice(crate::nostr::CancelOutcome::Cancelled); + } else { + // Mark the meta cancelled FIRST so any S2 still at the decide() + // stage is dropped, THEN cancel the grin tx to free the outputs. + service + .store + .update_tx_status(slate_id, crate::nostr::NostrSendStatus::Cancelled); + if let Err(e) = w.cancel(tx.id) { + error!("nostr cancel send: wallet cancel failed: {e}"); + } + service.set_cancel_notice(crate::nostr::CancelOutcome::Cancelled); + did_cancel = true; + } + } + sync_wallet_data(&w, false); + // Best-effort void so a recipient who catches up later drops the dead + // slate. They're likely offline (that's why the payment stalled), so a + // failure here is expected and harmless — the local reclaim stands. + if did_cancel { + if let Err(e) = service.send_control_dm(&meta.npub, slate_id, &[]).await { + info!("nostr cancel send: void dispatch failed (recipient offline?): {e}"); + } + } + } + WalletTask::NostrResend(id) => { + let Some(service) = w.nostr_service() else { + return; + }; + if let Some(s) = w.get_tx_slate(*id) { + let slate_id = s.id.to_string(); + if let Some(meta) = service.store.tx_meta(&slate_id) { + if let Some(text) = w.read_slatepack_text(s.id, &s.state) { + match service + .send_payment_dm(&meta.npub, &text, meta.note.as_deref(), &[]) + .await + { + Ok(event_id) => { + let mut meta = meta.clone(); + meta.sent_event_id = Some(event_id); + if meta.status == crate::nostr::NostrSendStatus::SendFailed + || meta.status == crate::nostr::NostrSendStatus::Created + { + meta.status = match meta.direction { + crate::nostr::NostrTxDirection::RequestedByUs => { + crate::nostr::NostrSendStatus::AwaitingI2 + } + _ => crate::nostr::NostrSendStatus::AwaitingS2, + }; + } + meta.updated_at = crate::nostr::unix_time(); + service.store.save_tx_meta(&meta); + } + Err(e) => error!("nostr resend failed: {e}"), + } + } + } + } + } + WalletTask::NostrPayRequest(request_id) => { + let Some(service) = w.nostr_service() else { + return; + }; + let Some(mut request) = service.store.request(request_id) else { + error!("nostr pay: request not found"); + return; + }; + if request.status != crate::nostr::RequestStatus::Pending { + error!("nostr pay: request is not pending"); + return; + } + // Drive the approve button's busy/failed state so it doesn't stay + // greyed forever if the pay can't go through. + service.set_send_phase(crate::nostr::send_phase::WORKING); + // Re-parse and re-validate the stored slatepack: it must still be + // an Invoice1 (or a Standard1 surfaced under a strict policy). + match w.parse_slatepack(&request.slatepack) { + Ok((s, _)) if s.state == SlateState::Invoice1 => match w.nostr_pay(&s) { + Ok((reply, text)) => { + let now = crate::nostr::unix_time(); + service.store.save_tx_meta(&crate::nostr::TxNostrMeta { + ver: 1, + slate_id: reply.id.to_string(), + npub: request.npub.clone(), + direction: crate::nostr::NostrTxDirection::RequestedOfUs, + note: request.note.clone(), + status: crate::nostr::NostrSendStatus::ReceivedNoReply, + sent_event_id: None, + received_rumor_id: Some(request.rumor_id.clone()), + created_at: now, + updated_at: now, + }); + match service + .send_payment_dm(&request.npub, &text, None, &[]) + .await + { + Ok(event_id) => { + if let Some(mut meta) = service.store.tx_meta(&reply.id.to_string()) + { + meta.status = + crate::nostr::NostrSendStatus::PaidAwaitingFinalize; + meta.sent_event_id = Some(event_id); + meta.updated_at = crate::nostr::unix_time(); + service.store.save_tx_meta(&meta); + } + } + Err(e) => error!("nostr pay reply dispatch failed: {e}"), + } + request.status = crate::nostr::RequestStatus::Approved; + service.store.save_request(&request); + service.set_send_phase(crate::nostr::send_phase::SENT); + sync_wallet_data(&w, false); + } + Err(e) => { + error!("nostr pay failed: {:?}", e); + service.fail_send(friendly_send_error(&e)); + } + }, + Ok((s, _)) if s.state == SlateState::Standard1 => { + // Incoming payment surfaced under Contacts/Ask policy: + // receiving is safe, process like an auto-receive. + match w.nostr_receive(&s) { + Ok((reply, text)) => { + let now = crate::nostr::unix_time(); + service.store.save_tx_meta(&crate::nostr::TxNostrMeta { + ver: 1, + slate_id: reply.id.to_string(), + npub: request.npub.clone(), + direction: crate::nostr::NostrTxDirection::Received, + note: request.note.clone(), + status: crate::nostr::NostrSendStatus::ReceivedNoReply, + sent_event_id: None, + received_rumor_id: Some(request.rumor_id.clone()), + created_at: now, + updated_at: now, + }); + match service + .send_payment_dm(&request.npub, &text, None, &[]) + .await + { + Ok(event_id) => { + if let Some(mut meta) = + service.store.tx_meta(&reply.id.to_string()) + { + meta.status = crate::nostr::NostrSendStatus::RepliedS2; + meta.sent_event_id = Some(event_id); + meta.updated_at = crate::nostr::unix_time(); + service.store.save_tx_meta(&meta); + } + } + Err(e) => error!("nostr accept reply dispatch failed: {e}"), + } + request.status = crate::nostr::RequestStatus::Approved; + service.store.save_request(&request); + service.set_send_phase(crate::nostr::send_phase::SENT); + sync_wallet_data(&w, false); + } + Err(e) => { + error!("nostr accept failed: {:?}", e); + service.fail_send(friendly_send_error(&e)); + } + } + } + _ => { + error!("nostr pay: stored slatepack is not payable"); + service.fail_send("This request is no longer payable.".to_string()); + request.status = crate::nostr::RequestStatus::Expired; + service.store.save_request(&request); + } + } + } + }; +} + +/// Refresh [`WalletData`] from local base or node. +fn sync_wallet_data(wallet: &Wallet, from_node: bool) { + // Update info sync progress at separate thread. + let wallet_info = wallet.clone(); + let (info_tx, info_rx) = mpsc::channel::(); + thread::spawn(move || { + while let Ok(m) = info_rx.recv() { + match m { + StatusMessage::UpdatingOutputs(_) => {} + StatusMessage::UpdatingTransactions(_) => {} + StatusMessage::FullScanWarn(_) => {} + StatusMessage::Scanning(_, progress) => { + wallet_info + .info_sync_progress + .store(progress, Ordering::Relaxed); + } + StatusMessage::ScanningComplete(_) => { + wallet_info.info_sync_progress.store(100, Ordering::Relaxed); + } + StatusMessage::UpdateWarning(_) => {} + } + } + }); + + let config = wallet.get_config(); + + // Retrieve wallet info. + let r_inst = wallet.instance.as_ref().read(); + if r_inst.is_some() { + let instance = r_inst.clone().unwrap(); + if let Ok((_, info)) = retrieve_summary_info( + instance.clone(), + wallet.keychain_mask().as_ref(), + &Some(info_tx), + from_node, + config.min_confirmations, + ) { + // Do not retrieve txs if wallet was closed or its first sync. + if !wallet.is_open() + || wallet.is_closing() + || (!from_node && info.last_confirmed_height == 0) + { + return; + } + + // Setup accounts data. + let last_height = info.last_confirmed_height; + let spendable = if wallet.get_data().is_none() { + None + } else { + Some(info.amount_currently_spendable) + }; + update_accounts(wallet, last_height, spendable); + + if wallet.info_sync_progress() == 100 || !from_node { + // Transactions limit setup. + let txs_limit = { + let r_data = wallet.data.read(); + if r_data.is_some() { + let data = r_data.as_ref().unwrap(); + data.txs_limit + } else { + WalletData::TXS_LIMIT + } + }; + // Update wallet info. + { + let mut w_data = wallet.data.write(); + if w_data.is_some() { + w_data.as_mut().unwrap().info = info; + } else { + *w_data = Some(WalletData { + info, + txs: None, + txs_limit, + }); + } + } + // Update wallet transactions. + if update_txs(wallet, txs_limit).is_ok() { + if !wallet.from_node.load(Ordering::Relaxed) { + wallet.from_node.store(from_node, Ordering::Relaxed); + } + wallet.reset_sync_attempts(); + return; + } + } + } + } + + // Reset progress. + wallet.info_sync_progress.store(0, Ordering::Relaxed); + + // Exit if wallet was closed or closing. + if !wallet.is_open() || wallet.is_closing() { + return; + } + + // Set an error if data was not loaded after opening or increment attempts count. + if wallet.get_data().is_none() { + wallet.set_sync_error(true); + } else { + wallet.increment_sync_attempts(); + } + + // Set an error if maximum number of attempts was reached. + if wallet.get_sync_attempts() >= SYNC_ATTEMPTS { + wallet.reset_sync_attempts(); + wallet.set_sync_error(true); + } +} + +/// Update wallet transactions. +fn update_txs(wallet: &Wallet, mut txs_limit: u32) -> Result<(), Error> { + let _ = wallet.clear_empty_txs(); + let txs = wallet.retrieve_txs(txs_limit)?; + + // Exit if wallet was closed. + if !wallet.is_open() || wallet.is_closing() { + return Err(Error::GenericError("Wallet is not open".to_string())); + } + + // Update limit with actual length. + let txs_size = txs.len() as u32; + let filter_size = txs.len() as u32; + + if txs_size > filter_size && txs_limit >= filter_size { + txs_limit = txs_limit - (txs_size - filter_size); + } + + // Update existing tx list. + let tx_height_store = TxHeightStore::new(wallet.get_config().get_extra_db_path()); + let data = wallet.get_data().unwrap(); + let data_txs = data.txs.unwrap_or(vec![]); + let mut new_txs: Vec = vec![]; + for tx in &txs { + let mut height: Option = None; + let mut broadcasting_height: Option = None; + let mut action: Option = None; + let mut action_error: Option = None; + let mut proof: Option = None; + for t in &data_txs { + if t.data.id == tx.id { + action = t.action.clone(); + action_error = t.action_error.clone(); + height = t.height; + broadcasting_height = t.broadcasting_height; + proof = t.proof.clone(); + break; + } + } + let mut new = WalletTx::new( + tx.clone(), + proof.clone(), + wallet, + height, + broadcasting_height, + action, + action_error, + ); + // Payment proof setup. + if proof.is_none() + && tx.payment_proof.is_some() + && tx + .payment_proof + .as_ref() + .unwrap() + .receiver_signature + .is_some() + && tx + .payment_proof + .as_ref() + .unwrap() + .sender_signature + .is_some() + && tx.kernel_excess.is_some() + { + if let Ok(p) = wallet.get_payment_proof(Some(tx.id), tx.tx_slate_id) { + proof = p.clone(); + new.proof = proof; + } + } + // Initial tx heights setup. + if let Some(slate_id) = tx.tx_slate_id { + let id = slate_id.to_string(); + if height.is_none() && tx.confirmed { + height = if let Some(height) = tx_height_store.read_tx_height(&id) { + Some(height) + } else { + tx_height_store.delete_broadcasting_height(&id); + let h = wallet.tx_height(&new)?; + if let Some(h) = h { + tx_height_store.write_tx_height(&id, h); + } + h + }; + new.height = height; + } else if broadcasting_height.is_none() && new.broadcasting() { + let br_height = tx_height_store.read_broadcasting_height(&id); + broadcasting_height = if br_height.is_none() || br_height.unwrap() == 0 { + let h = data.info.last_confirmed_height; + tx_height_store.write_broadcasting_height(&id, h); + Some(h) + } else { + Some(br_height.unwrap()) + }; + new.broadcasting_height = broadcasting_height; + } + } + if !new.deleting() { + new_txs.push(new); + } + } + // Update wallet txs. + let mut w_data = wallet.data.write(); + if w_data.is_some() { + w_data.as_mut().unwrap().txs_limit = txs_limit; + w_data.as_mut().unwrap().txs = Some(new_txs); + } + Ok(()) +} + +/// Start Foreign API server to receive txs over transport and mining rewards. +fn start_api_server(wallet: &Wallet) -> Result<(ApiServer, u16), Error> { + let host = "127.0.0.1"; + let port = wallet + .get_config() + .api_port + .unwrap_or(rand::rng().random_range(10000..30000)); + let free_port = (port..) + .find(|port| { + return match TcpListener::bind((host, port.to_owned())) { + Ok(_) => { + let node_p2p_port = NodeConfig::get_p2p_port(); + let node_api_port = NodeConfig::get_api_ip_port().1; + let free = + port.to_string() != node_p2p_port && port.to_string() != node_api_port; + if free { + let mut config = wallet.config.write(); + config.api_port = Some(*port); + config.save(); + } + free + } + Err(_) => false, + }; + }) + .unwrap(); + + // Setup API server address. + let api_addr = format!("{}:{}", host, free_port); + + // Start Foreign API server thread. + let r_inst = wallet.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let keychain_mask = wallet.keychain_mask(); + let api_handler_v2 = ForeignAPIHandlerV2::new( + instance, + Arc::new(Mutex::new(keychain_mask)), + false, + Mutex::new(None), + ); + let mut router = Router::new(); + router + .add_route("/v2/foreign", Arc::new(api_handler_v2)) + .map_err(|_| Error::GenericError("Router failed to add route".to_string()))?; + + let api_chan: &'static mut (oneshot::Sender<()>, oneshot::Receiver<()>) = + Box::leak(Box::new(oneshot::channel::<()>())); + + let mut apis = ApiServer::new(); + let socket_addr: SocketAddr = api_addr.parse().unwrap(); + let _ = apis + .start(socket_addr, router, None, api_chan) + .map_err(|_| Error::GenericError("API thread failed to start".to_string()))?; + Ok((apis, free_port)) +} + +/// Update wallet accounts data. +fn update_accounts(wallet: &Wallet, height: u64, spendable: Option) { + let current_account = wallet.get_config().account; + if let Some(amount) = spendable { + let mut accounts = wallet.accounts.read().clone(); + for a in accounts.iter_mut() { + if a.label == current_account { + a.spendable_amount = amount; + } + } + // Save accounts data. + let mut w_data = wallet.accounts.write(); + *w_data = accounts; + } else { + let r_inst = wallet.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let mut api = Owner::new(instance, None); + let key_mask = wallet.keychain_mask(); + let _ = controller::owner_single_use(None, key_mask.as_ref(), Some(&mut api), |api, m| { + let mut accounts = vec![]; + for a in api.accounts(m)? { + api.set_active_account(m, a.label.as_str())?; + // Calculate account balance. + if let Some(spendable_amount) = wallet.account_balance(height, api, m) { + accounts.push(WalletAccount { + spendable_amount, + label: a.label, + path: a.path.to_bip_32_string(), + }); + } + } + accounts.sort_by_key(|w| w.label != current_account); + + // Save accounts data. + let mut w_data = wallet.accounts.write(); + *w_data = accounts; + + // Set current active account from config. + api.set_active_account(m, current_account.as_str())?; + + Ok(()) + }); + } +} + +/// Scan wallet's outputs, repairing and restoring missing outputs if required. +fn repair_wallet(wallet: &Wallet) { + let (info_tx, info_rx) = mpsc::channel::(); + // Update scan progress at separate thread. + let wallet_scan = wallet.clone(); + thread::spawn(move || { + while let Ok(m) = info_rx.recv() { + match m { + StatusMessage::UpdatingOutputs(_) => {} + StatusMessage::UpdatingTransactions(_) => {} + StatusMessage::FullScanWarn(_) => {} + StatusMessage::Scanning(_, progress) => { + wallet_scan + .repair_progress + .store(progress, Ordering::Relaxed); + } + StatusMessage::ScanningComplete(_) => { + wallet_scan.repair_progress.store(100, Ordering::Relaxed); + } + StatusMessage::UpdateWarning(_) => {} + } + } + }); + + let r_inst = wallet.instance.as_ref().read(); + let instance = r_inst.clone().unwrap(); + let api = Owner::new(instance, Some(info_tx)); + // Start wallet scanning. + match api.scan(wallet.keychain_mask().as_ref(), Some(1), false) { + Ok(()) => { + // Set sync error if scanning was not complete and wallet is open. + if wallet.is_open() && wallet.repair_progress.load(Ordering::Relaxed) != 100 { + wallet.set_sync_error(true); + } else { + wallet.repair_needed.store(false, Ordering::Relaxed); + } + } + Err(_) => { + // Set sync error if wallet is open. + if wallet.is_open() { + wallet.set_sync_error(true); + } else { + wallet.repair_needed.store(false, Ordering::Relaxed); + } + } + } + + // Reset repair progress. + wallet.repair_progress.store(0, Ordering::Relaxed); +} diff --git a/tests/i18n_keys.rs b/tests/i18n_keys.rs new file mode 100644 index 00000000..6bc70c30 --- /dev/null +++ b/tests/i18n_keys.rs @@ -0,0 +1,125 @@ +// Copyright 2026 The Goblin Developers +// +// 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. + +//! Localization drift guard. en.yml is the source of truth: every `goblin.*` +//! key (and its `%{...}` interpolation placeholders) must exist, identically, +//! in every other locale, so the language picker never falls back to a raw key +//! or a string that drops a value. Fails CI the moment a translation lags. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +/// The locales shipped alongside English. +const OTHER_LOCALES: &[&str] = &["de", "fr", "ru", "tr", "zh-CN"]; + +/// Flatten a YAML mapping into dotted leaf keys → string value. +fn flatten(value: &serde_yaml::Value, prefix: &str, out: &mut BTreeMap) { + match value { + serde_yaml::Value::Mapping(map) => { + for (k, v) in map { + let key = k.as_str().unwrap_or_default(); + let next = if prefix.is_empty() { + key.to_string() + } else { + format!("{prefix}.{key}") + }; + flatten(v, &next, out); + } + } + other => { + let s = match other { + serde_yaml::Value::String(s) => s.clone(), + serde_yaml::Value::Bool(b) => b.to_string(), + serde_yaml::Value::Number(n) => n.to_string(), + _ => String::new(), + }; + out.insert(prefix.to_string(), s); + } + } +} + +/// Load a locale file flattened to `goblin.*` keys only. +fn load_goblin(locale: &str) -> BTreeMap { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("locales") + .join(format!("{locale}.yml")); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + let doc: serde_yaml::Value = + serde_yaml::from_str(&text).unwrap_or_else(|e| panic!("invalid YAML in {locale}.yml: {e}")); + let mut all = BTreeMap::new(); + flatten(&doc, "", &mut all); + all.into_iter() + .filter(|(k, _)| k.starts_with("goblin.")) + .collect() +} + +/// `%{name}` placeholders contained in a value, sorted. +fn placeholders(s: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + let bytes = s.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'%' && bytes[i + 1] == b'{' { + if let Some(end) = s[i..].find('}') { + out.insert(s[i..i + end + 1].to_string()); + i += end + 1; + continue; + } + } + i += 1; + } + out +} + +#[test] +fn every_locale_has_all_goblin_keys() { + let en = load_goblin("en"); + assert!( + en.len() > 300, + "en.yml goblin block looks too small ({} keys) — did it load?", + en.len() + ); + let en_keys: BTreeSet<&String> = en.keys().collect(); + + let mut problems = Vec::new(); + for &loc in OTHER_LOCALES { + let other = load_goblin(loc); + let other_keys: BTreeSet<&String> = other.keys().collect(); + for missing in en_keys.difference(&other_keys) { + problems.push(format!("{loc}: MISSING key {missing}")); + } + for extra in other_keys.difference(&en_keys) { + problems.push(format!("{loc}: EXTRA key {extra} (not in en.yml)")); + } + // Placeholder parity: a translation must carry the same %{...} args. + for (k, en_val) in &en { + if let Some(other_val) = other.get(k) { + if placeholders(en_val) != placeholders(other_val) { + problems.push(format!( + "{loc}: placeholder mismatch in {k} (en {:?} vs {:?})", + placeholders(en_val), + placeholders(other_val) + )); + } + } + } + } + + assert!( + problems.is_empty(), + "localization drift detected:\n{}", + problems.join("\n") + ); +} diff --git a/tests/nostr_e2e.rs b/tests/nostr_e2e.rs new file mode 100644 index 00000000..cdb2cdb3 --- /dev/null +++ b/tests/nostr_e2e.rs @@ -0,0 +1,454 @@ +// End-to-end Nostr exchange test against the live Goblin relay. +// +// Proves the NIP-17 payment-message path: gift-wrap send, subscribe, unwrap, +// seal-author verification, subject tag, and Goblin slatepack extraction. +// Network-dependent — run explicitly: +// cargo test --test nostr_e2e -- --ignored --nocapture + +use std::time::Duration; + +use grim::nostr::protocol; +use nostr_sdk::prelude::*; + +const RELAY: &str = "wss://nrelay.us-ea.st"; + +/// A small but valid-looking slatepack armor block for extraction testing. +const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \ + pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \ + dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \ + ENDSLATEPACK."; + +#[tokio::test] +#[ignore] +async fn nip17_slatepack_roundtrip() { + nip17_roundtrip_over(RELAY).await; +} + +/// Same NIP-17 payment roundtrip over relay.damus.io — proves Goblin gift +/// wraps transit a top public relay, not only the relay we run. +/// Run: cargo test --test nostr_e2e nip17_roundtrip_damus -- --ignored --nocapture +#[tokio::test] +#[ignore] +async fn nip17_roundtrip_damus() { + nip17_roundtrip_over("wss://relay.damus.io").await; +} + +/// And over nos.lol, the other large public relay in DEFAULT_RELAYS. +/// Run: cargo test --test nostr_e2e nip17_roundtrip_nos_lol -- --ignored --nocapture +#[tokio::test] +#[ignore] +async fn nip17_roundtrip_nos_lol() { + nip17_roundtrip_over("wss://nos.lol").await; +} + +/// The shared roundtrip, parameterized by relay: Bob advertises a kind-10050 +/// DM relay and subscribes to gift wraps; Alice sends a NIP-17 payment DM; Bob +/// unwraps it, verifies the seal author, and extracts the slatepack + subject. +async fn nip17_roundtrip_over(relay: &str) { + let alice = Keys::generate(); + let bob = Keys::generate(); + println!("alice: {}", alice.public_key().to_bech32().unwrap()); + println!("bob: {}", bob.public_key().to_bech32().unwrap()); + + // Bob's client: connect, advertise DM relays, subscribe to gift wraps. + let bob_client = Client::new(bob.clone()); + bob_client.add_relay(relay).await.unwrap(); + bob_client.connect().await; + tokio::time::sleep(Duration::from_secs(2)).await; + + // Publish Bob's kind-10050 DM relay list so senders find this relay. + let dm_relays = EventBuilder::new(Kind::InboxRelays, "") + .tag(Tag::custom(TagKind::custom("relay"), [relay.to_string()])); + bob_client.send_event_builder(dm_relays).await.unwrap(); + + let filter = Filter::new() + .kind(Kind::GiftWrap) + .pubkey(bob.public_key()) + .since(Timestamp::now() - Duration::from_secs(3 * 86_400)); + bob_client.subscribe(filter, None).await.unwrap(); + + // Alice's client: connect and send a NIP-17 payment DM to Bob. + let alice_client = Client::new(alice.clone()); + alice_client.add_relay(relay).await.unwrap(); + alice_client.connect().await; + tokio::time::sleep(Duration::from_secs(2)).await; + + let content = protocol::build_payment_content(SLATEPACK); + let tags = protocol::build_rumor_tags(Some("lunch :)")); + alice_client + .send_private_msg_to([relay], bob.public_key(), content, tags) + .await + .unwrap(); + println!("alice sent gift-wrapped payment DM"); + + // Bob waits for the gift wrap, unwraps and validates it. + let mut notifications = bob_client.notifications(); + let result = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await { + if event.kind != Kind::GiftWrap { + continue; + } + let unwrapped = match bob_client.unwrap_gift_wrap(&event).await { + Ok(u) => u, + Err(_) => continue, + }; + // Seal-author check (the NIP-17 invariant our ingest enforces). + assert_eq!( + unwrapped.rumor.pubkey, unwrapped.sender, + "rumor author must equal seal signer" + ); + assert_eq!(unwrapped.sender, alice.public_key(), "sender must be Alice"); + assert_eq!(unwrapped.rumor.kind, Kind::PrivateDirectMessage); + return unwrapped; + } + } + }) + .await + .expect("timed out waiting for gift wrap"); + + // The slatepack must round-trip intact, and the subject must survive. + let armor = protocol::extract_slatepack(&result.rumor.content) + .expect("slatepack must extract from rumor"); + assert!(armor.starts_with("BEGINSLATEPACK.")); + assert!(armor.ends_with("ENDSLATEPACK.")); + let subject = protocol::extract_subject(&result.rumor.tags); + assert_eq!(subject.as_deref(), Some("lunch :)")); + + println!("✓ NIP-17 slatepack roundtrip verified over {relay}"); + bob_client.disconnect().await; + alice_client.disconnect().await; +} + +/// Register a fresh name on goblin.st with a real NIP-98 signature, then +/// resolve it back — proves the live identity server end-to-end. +/// Run: cargo test --test nostr_e2e nip05 -- --ignored --nocapture +#[tokio::test] +#[ignore] +async fn nip05_registration_roundtrip() { + use base64::Engine; + use sha2::{Digest, Sha256}; + use std::process::Command; + + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + // Unique-ish name from the pubkey suffix (lowercase alnum). + let name = format!("t{}", &pubkey[..8]); + let server = "https://goblin.st"; + let url = format!("{server}/api/v1/register"); + let body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string(); + + // Build the NIP-98 kind-27235 auth event (same shape as the client). + let payload_hash = hex::encode(Sha256::digest(body.as_bytes())); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [url.clone()])) + .tag(Tag::custom(TagKind::custom("method"), ["POST".to_string()])) + .tag(Tag::custom(TagKind::custom("payload"), [payload_hash])) + .sign_with_keys(&keys) + .unwrap(); + let auth = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(event.as_json()) + ); + + // POST the registration via curl (avoids pulling an HTTP client dep). + let out = Command::new("curl") + .args([ + "-s", + "-X", + "POST", + &url, + "-H", + &format!("Authorization: {auth}"), + "-H", + "Content-Type: application/json", + "-d", + &body, + ]) + .output() + .expect("curl register"); + let resp = String::from_utf8_lossy(&out.stdout); + println!("register response: {resp}"); + assert!( + resp.contains("\"nip05\""), + "registration should return a nip05 identifier, got: {resp}" + ); + assert!(resp.contains(&format!("{name}@goblin.st"))); + + // Resolve it back from the well-known endpoint. + let wk = Command::new("curl") + .args([ + "-s", + &format!("{server}/.well-known/nostr.json?name={name}"), + ]) + .output() + .expect("curl well-known"); + let wk_body = String::from_utf8_lossy(&wk.stdout); + println!("well-known response: {wk_body}"); + let resolved = protocol_parse_pubkey(&wk_body, &name); + assert_eq!(resolved.as_deref(), Some(pubkey.as_str())); + + // Clean up: release the test name. + let del_url = format!("{server}/api/v1/register/{name}"); + let del_event = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [del_url.clone()])) + .tag(Tag::custom( + TagKind::custom("method"), + ["DELETE".to_string()], + )) + .sign_with_keys(&keys) + .unwrap(); + let del_auth = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(del_event.as_json()) + ); + let _ = Command::new("curl") + .args([ + "-s", + "-X", + "DELETE", + &del_url, + "-H", + &format!("Authorization: {del_auth}"), + ]) + .output(); + + println!("✓ NIP-05 registration + resolution verified on {server}"); +} + +/// Minimal well-known pubkey extractor for the test. +fn protocol_parse_pubkey(body: &str, name: &str) -> Option { + let doc: serde_json::Value = serde_json::from_str(body).ok()?; + doc.get("names")?.get(name)?.as_str().map(|s| s.to_string()) +} + +/// Live avatar pipeline e2e against goblin.st: register → upload a processed +/// PNG (NIP-98 by the owner) → profile shows the hash → GET serves a 256px +/// PNG with the hardened headers → 6th change is rate-limited → release +/// purges both the name and its avatar. +/// Run: cargo test --test nostr_e2e avatar -- --ignored --nocapture +#[tokio::test] +#[ignore] +async fn avatar_upload_roundtrip() { + use base64::Engine; + use sha2::{Digest, Sha256}; + use std::process::Command; + + let server = "https://goblin.st"; + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let name = format!("a{}", &pubkey[..8]); + + let nip98 = |url: &str, method: &str, body: &[u8]| -> String { + let mut b = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [url.to_string()])) + .tag(Tag::custom(TagKind::custom("method"), [method.to_string()])); + if !body.is_empty() { + b = b.tag(Tag::custom( + TagKind::custom("payload"), + [hex::encode(Sha256::digest(body))], + )); + } + let ev = b.sign_with_keys(&keys).unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(ev.as_json()) + ) + }; + + // Register the name first. + let reg_url = format!("{server}/api/v1/register"); + let reg_body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string(); + let out = Command::new("curl") + .args([ + "-s", + "-X", + "POST", + ®_url, + "-H", + &format!( + "Authorization: {}", + nip98(®_url, "POST", reg_body.as_bytes()) + ), + "-H", + "Content-Type: application/json", + "-d", + ®_body, + ]) + .output() + .expect("curl register"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("\"nip05\""), + "register failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // Build a real PNG via the client pipeline (also strips metadata). + let raw = { + use ::image::{ImageEncoder, RgbaImage}; + let img = RgbaImage::from_fn(640, 480, |x, y| { + ::image::Rgba([(x % 256) as u8, (y % 256) as u8, 90, 255]) + }); + let mut v = Vec::new(); + ::image::DynamicImage::ImageRgba8(img) + .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) + .unwrap(); + v + }; + let png = grim::nostr::avatar::process_avatar_bytes(&raw).expect("process"); + let png_path = std::env::temp_dir().join(format!("{name}.png")); + std::fs::write(&png_path, &png).unwrap(); + let av_url = format!("{server}/api/v1/avatar/{name}"); + + // Upload (raw bytes; payload hash over the PNG). + let out = Command::new("curl") + .args([ + "-s", + "-X", + "POST", + &av_url, + "-H", + &format!("Authorization: {}", nip98(&av_url, "POST", &png)), + "-H", + "Content-Type: application/octet-stream", + "--data-binary", + &format!("@{}", png_path.display()), + ]) + .output() + .expect("curl upload"); + let resp = String::from_utf8_lossy(&out.stdout); + println!("upload: {resp}"); + let hash = serde_json::from_str::(&resp) + .ok() + .and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from)) + .expect("upload should return a hash"); + + // Profile exposes the hash. + let prof = Command::new("curl") + .args(["-s", &format!("{server}/api/v1/profile/{name}")]) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&prof.stdout).contains(&hash), + "profile should carry the avatar hash" + ); + + // GET serves a 256px PNG with hardened headers. + let head = Command::new("curl") + .args(["-sI", &format!("{server}/api/v1/avatar/{hash}.png")]) + .output() + .unwrap(); + let head = String::from_utf8_lossy(&head.stdout).to_lowercase(); + assert!(head.contains("content-type: image/png"), "headers: {head}"); + assert!(head.contains("nosniff"), "missing nosniff: {head}"); + assert!( + head.contains("immutable"), + "missing immutable cache: {head}" + ); + let got = Command::new("curl") + .args(["-s", &format!("{server}/api/v1/avatar/{hash}.png")]) + .output() + .unwrap(); + assert!(got.stdout.starts_with(&[0x89, b'P', b'N', b'G'])); + let served = ::image::load_from_memory(&got.stdout).expect("served bytes decode"); + assert_eq!((served.width(), served.height()), (256, 256)); + + // Daily limit: 4 more changes succeed (1 done = 5 total), the 6th is 429. + for i in 0..4 { + // Vary the pixels so each upload is a distinct hash. + let raw = { + use ::image::{ImageEncoder, RgbaImage}; + let img = RgbaImage::from_pixel(64, 64, ::image::Rgba([i as u8 * 40, 10, 10, 255])); + let mut v = Vec::new(); + ::image::DynamicImage::ImageRgba8(img) + .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) + .unwrap(); + v + }; + let png = grim::nostr::avatar::process_avatar_bytes(&raw).unwrap(); + std::fs::write(&png_path, &png).unwrap(); + let out = Command::new("curl") + .args([ + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "POST", + &av_url, + "-H", + &format!("Authorization: {}", nip98(&av_url, "POST", &png)), + "--data-binary", + &format!("@{}", png_path.display()), + ]) + .output() + .unwrap(); + println!("change {}: {}", i + 2, String::from_utf8_lossy(&out.stdout)); + } + // 6th change → 429. + let png = grim::nostr::avatar::process_avatar_bytes(&{ + use ::image::{ImageEncoder, RgbaImage}; + let img = RgbaImage::from_pixel(48, 48, ::image::Rgba([200, 200, 0, 255])); + let mut v = Vec::new(); + ::image::DynamicImage::ImageRgba8(img) + .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) + .unwrap(); + v + }) + .unwrap(); + std::fs::write(&png_path, &png).unwrap(); + let out = Command::new("curl") + .args([ + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "POST", + &av_url, + "-H", + &format!("Authorization: {}", nip98(&av_url, "POST", &png)), + "--data-binary", + &format!("@{}", png_path.display()), + ]) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + "429", + "6th avatar change in 24h must be rate-limited" + ); + + // Release the name → avatar purged. + let del_url = format!("{server}/api/v1/register/{name}"); + let _ = Command::new("curl") + .args([ + "-s", + "-X", + "DELETE", + &del_url, + "-H", + &format!("Authorization: {}", nip98(&del_url, "DELETE", &[])), + ]) + .output(); + let after = Command::new("curl") + .args([ + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + &format!("{server}/api/v1/profile/{name}"), + ]) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&after.stdout), + "404", + "profile should 404 after release" + ); + let _ = std::fs::remove_file(&png_path); + println!("✓ avatar upload/serve/limit/release-purge verified on {server}"); +} diff --git a/tests/replay_check.rs b/tests/replay_check.rs new file mode 100644 index 00000000..f01243b2 --- /dev/null +++ b/tests/replay_check.rs @@ -0,0 +1,112 @@ +use base64::Engine; +use nostr_sdk::prelude::*; +use sha2::{Digest, Sha256}; +use std::process::Command; + +#[tokio::test] +#[ignore] +async fn replay_and_double_name_rejected() { + let keys = Keys::generate(); + let pk = keys.public_key().to_hex(); + let server = "https://goblin.st"; + + // Build a register POST for name A. + let name_a = format!("r{}", &pk[..7]); + let url = format!("{server}/api/v1/register"); + let body = serde_json::json!({"name": name_a, "pubkey": pk}).to_string(); + let ph = hex::encode(Sha256::digest(body.as_bytes())); + let ev = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [url.clone()])) + .tag(Tag::custom(TagKind::custom("method"), ["POST".to_string()])) + .tag(Tag::custom(TagKind::custom("payload"), [ph])) + .sign_with_keys(&keys) + .unwrap(); + let auth = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(ev.as_json()) + ); + let post = |a: &str, b: &str| { + String::from_utf8_lossy( + &Command::new("curl") + .args([ + "-s", + "-X", + "POST", + &url, + "-H", + &format!("Authorization: {a}"), + "-H", + "Content-Type: application/json", + "-d", + b, + ]) + .output() + .unwrap() + .stdout, + ) + .to_string() + }; + let r1 = post(&auth, &body); + let r2 = post(&auth, &body); // exact replay (same auth event id) + println!("first: {r1}"); + println!("replay: {r2}"); + assert!(r1.contains("nip05"), "first register should succeed"); + assert!( + r2.contains("replayed"), + "replay should be rejected, got: {r2}" + ); + + // Second DISTINCT name with a FRESH signature but same pubkey -> blocked. + // Two protections can fire here: the per-pubkey name-change cooldown (one + // change per 10 min, which the just-completed register of name_a triggers) + // and the one-active-name-per-pubkey rule. The cooldown is checked first, so + // within 10 min of a successful register a same-pubkey second register is + // rejected with name_change_cooldown; either rejection is a valid block. + let name_b = format!("s{}", &pk[..7]); + let body_b = serde_json::json!({"name": name_b, "pubkey": pk}).to_string(); + let ph_b = hex::encode(Sha256::digest(body_b.as_bytes())); + let ev_b = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [url.clone()])) + .tag(Tag::custom(TagKind::custom("method"), ["POST".to_string()])) + .tag(Tag::custom(TagKind::custom("payload"), [ph_b])) + .sign_with_keys(&keys) + .unwrap(); + let auth_b = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(ev_b.as_json()) + ); + let r3 = post(&auth_b, &body_b); + println!("2nd name: {r3}"); + assert!( + r3.contains("already has a name") + || r3.contains("pubkey already") + || r3.contains("name_change_cooldown"), + "a same-pubkey second name should be blocked (one-name rule or cooldown), got: {r3}" + ); + + // Cleanup name A. + let del_url = format!("{server}/api/v1/register/{name_a}"); + let ev_d = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [del_url.clone()])) + .tag(Tag::custom( + TagKind::custom("method"), + ["DELETE".to_string()], + )) + .sign_with_keys(&keys) + .unwrap(); + let auth_d = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(ev_d.as_json()) + ); + let _ = Command::new("curl") + .args([ + "-s", + "-X", + "DELETE", + &del_url, + "-H", + &format!("Authorization: {auth_d}"), + ]) + .output(); + println!("✓ replay + one-name-per-pubkey enforced"); +} diff --git a/wallet b/wallet new file mode 160000 index 00000000..c2db7545 --- /dev/null +++ b/wallet @@ -0,0 +1 @@ +Subproject commit c2db754552b9e5c57c4a843c68744df0cc744ff8 diff --git a/wix/License.rtf b/wix/License.rtf new file mode 100644 index 00000000..d0551b4b --- /dev/null +++ b/wix/License.rtf @@ -0,0 +1,70 @@ +{\rtf1\ansi\ansicpg1252\deff0\nouicompat{\fonttbl{\f0\fnil\fcharset0 Arial;}{\f1\fnil\fcharset0 Courier New;}} +{\colortbl ;\red0\green0\blue255;} +{\*\generator Riched20 10.0.15063}\viewkind4\uc1 +\pard\sa180\qc\fs24\lang9 Apache License\line Version 2.0, January 2004\line {{\field{\*\fldinst{HYPERLINK http://www.apache.org/licenses/ }}{\fldrslt{http://www.apache.org/licenses/\ul0\cf0}}}}\f0\fs24\par +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\par + +\pard\fi-360\li360\sa180\tx360 1. Definitions.\par + +\pard\sa180 "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\par +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\par +"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.\par +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.\par +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\par +"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.\par +"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).\par +"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.\par +"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."\par +"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.\par + +\pard\fi-360\li360\sa180\tx360 2. Grant of Copyright License.\par + +\pard\sa180\tx360 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.\par + +\pard\fi-360\li360\sa180\tx360 3. Grant of Patent License.\par + +\pard\sa180\tx360 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.\par + +\pard\fi-360\li360\sa180\tx360 4. Redistribution. \par + +\pard\sa180\tx360 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:\par + +\pard +{\pntext\f0 a.\tab}{\*\pn\pnlvlbody\pnf0\pnindent0\pnstart1\pnlcltr{\pntxta.}} +\fi-360\li720\sa180\tx360 You must give any other recipients of the Work or Derivative Works a copy of this License; and\par +{\pntext\f0 b.\tab}You must cause any modified files to carry prominent notices stating that You changed the files; and\par +{\pntext\f0 c.\tab}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 \par +{\pntext\f0 d.\tab}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.\par + +\pard\sa180 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.\par + +\pard\fi-360\li360\sa180\tx360 5.\tab Submission of Contributions. \par + +\pard\sa180\tx360 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.\par + +\pard\fi-360\li360\sa180\tx360 6.\tab Trademarks. \par + +\pard\sa180\tx360 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.\par + +\pard\fi-360\li360\sa180\tx360 7.\tab Disclaimer of Warranty. \par + +\pard\sa180\tx360 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.\par + +\pard\fi-360\li360\sa180\tx360 8.\tab Limitation of Liability.\par + +\pard\sa180\tx360 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.\par + +\pard\fi-360\li360\sa180\tx360 9.\tab Accepting Warranty or Additional Liability. \par + +\pard\sa180\tx360 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.\par + +\pard\sa180\qc END OF TERMS AND CONDITIONS\par +How to apply the Apache License to your work.\par + +\pard\sa180 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.\par +\f1 Copyright (C) [YYYY] [Copyright Holder]\par +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\par +{{\field{\*\fldinst{HYPERLINK http://www.apache.org/licenses/LICENSE-2.0 }}{\fldrslt{http://www.apache.org/licenses/LICENSE-2.0\ul0\cf0}}}}\f1\fs24\par +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.\par +} + diff --git a/wix/Product.ico b/wix/Product.ico new file mode 100644 index 00000000..437a2ee4 Binary files /dev/null and b/wix/Product.ico differ diff --git a/wix/main.wxs b/wix/main.wxs new file mode 100644 index 00000000..c25f7159 --- /dev/null +++ b/wix/main.wxs @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed + + + + + + + + + \ No newline at end of file