Compare commits
422 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5445b48a69 | |||
| 56099539aa | |||
| bb3b8c4ecc | |||
| 2578a35cf7 | |||
| 63d5ca2b5f | |||
| 8c48d2f5ce | |||
| bfed0a1cb9 | |||
| 63bf92f172 | |||
| fc348c1843 | |||
| ea70923e83 | |||
| 3ebae8807c | |||
| ca038c6e14 | |||
| 6ce70aee7e | |||
| de7007269f | |||
| 6dff408766 | |||
| 6621dc6aaa | |||
| 329067e1c2 | |||
| 0f46145f46 | |||
| ba5ddf07ac | |||
| c05074faac | |||
| c4d26d3b7f | |||
| 695c3e6d4f | |||
| dda07dee0a | |||
| 1ac1186319 | |||
| 4cad00079e | |||
| 143f4230c9 | |||
| f714e4498a | |||
| 2e8829ef83 | |||
| 9d74d6fac5 | |||
| 4d5db923ea | |||
| f210180de6 | |||
| 1b5352da0d | |||
| 2cc023b905 | |||
| 993a438e18 | |||
| b1c3c07dac | |||
| 878f7728eb | |||
| 60e4e8b5a9 | |||
| b9ce88e996 | |||
| 60414e9477 | |||
| 7eefb54075 | |||
| cda1be992f | |||
| 9257f82dcf | |||
| 7f09598298 | |||
| 03741f7839 | |||
| 78f629d8d3 | |||
| 68d72fa853 | |||
| 95403516d5 | |||
| f2402eb24d | |||
| 413746dde3 | |||
| c3b23dc1a7 | |||
| 15c19303ff | |||
| 0438d70cae | |||
| 86f042facb | |||
| 6dbd0f8e9d | |||
| c72cda3039 | |||
| d53345ffdd | |||
| b1b9bd61af | |||
| a12f894dff | |||
| 0c60368280 | |||
| 9d36562bab | |||
| d8cf06b577 | |||
| 908df117e6 | |||
| 906fee9c71 | |||
| 32696438d3 | |||
| aa9847bb41 | |||
| ce1c071f3c | |||
| 87efc8bb2d | |||
| 8a6d442544 | |||
| 1848d0c796 | |||
| b51a46b943 | |||
| 3d1a721f29 | |||
| 176df6f93e | |||
| f7287bd9ad | |||
| 4c4b6cd5dc | |||
| 4aeda9c9dc | |||
| a4eadebef2 | |||
| 15d1aa1a21 | |||
| 242a3b9434 | |||
| edc1a09b2c | |||
| f31953f455 | |||
| d573ddedca | |||
| 3be6925ff8 | |||
| c7abd9cbfa | |||
| 512d216fee | |||
| eaefc58c5a | |||
| 2519e68dd5 | |||
| 73c0884f95 | |||
| f7b2150228 | |||
| 03924b5300 | |||
| a479189135 | |||
| e691a7b02d | |||
| f2b79cd70d | |||
| f20d1ee2c2 | |||
| 534c4cc86a | |||
| 558ac034b2 | |||
| 13bf8e830c | |||
| 57f319edfc | |||
| 748aebffb6 | |||
| b32085a423 | |||
| a9c65546e3 | |||
| b94241b82a | |||
| 8a1a69b739 | |||
| 01d17e25ee | |||
| cab38097fa | |||
| 0026fc3717 | |||
| 0fd04f14a4 | |||
| 3338f51de5 | |||
| 0fa8963bd2 | |||
| 70bba5d7ce | |||
| 0bb43e1e5d | |||
| fd52757549 | |||
| 6835bb1909 | |||
| 31bc74529c | |||
| 8d6943975b | |||
| 4c5d8abe7b | |||
| 4dc42bce4a | |||
| e2d5d92f18 | |||
| b001eb4712 | |||
| f14bd902ea | |||
| 33ab11933a | |||
| 6b05a2177e | |||
| 7bbe637414 | |||
| 9b6252de3a | |||
| 26debcf51c | |||
| 497b967fd0 | |||
| 05e18cf6c4 | |||
| 6e50b2b38a | |||
| 9bc96de398 | |||
| 5a525c50e1 | |||
| ba0af0968d | |||
| a0947aa47c | |||
| 06c6b8b4f5 | |||
| b19335d0bc | |||
| 40eb30fb75 | |||
| 8223e52570 | |||
| 875bd11bdb | |||
| 19e4cb664d | |||
| 18bc327a99 | |||
| 88e2fb0715 | |||
| feb38dc7cf | |||
| 28ecb5b1f4 | |||
| 024a9d0098 | |||
| 59cf46e1cb | |||
| 22255e0f2a | |||
| 7fdb8d272b | |||
| d043562058 | |||
| 096788c899 | |||
| ba914903eb | |||
| 162c5f88eb | |||
| ae0ff12935 | |||
| af203b8f9b | |||
| 1bd57cd88d | |||
| 8eea776111 | |||
| f5f6141881 | |||
| beb1a80c6a | |||
| 2a41689231 | |||
| dda3be7f86 | |||
| 65e9546f81 | |||
| 8f1175ff1a | |||
| 0ca2c7f372 | |||
| ee4752a95f | |||
| 366bbaeac6 | |||
| 4e1ada3188 | |||
| a499c91619 | |||
| 9f2ad32031 | |||
| 431cda358f | |||
| 149555cc0a | |||
| 72de1d5c05 | |||
| b4c64dae6b | |||
| e334386fe2 | |||
| a03758d383 | |||
| 7d75fc2ae0 | |||
| a8df3a20ba | |||
| 67514b8609 | |||
| 3a23438e17 | |||
| 86d4fde77d | |||
| b751aa256e | |||
| b2ef91e67d | |||
| b54fd3251f | |||
| 9bb5f1d66a | |||
| e56058ff33 | |||
| 45473ded7e | |||
| 6eec01bad6 | |||
| 35dbc3eca9 | |||
| 94bae256af | |||
| dae59744b3 | |||
| 7d28a31e18 | |||
| 6d5445f72f | |||
| 04417f1f53 | |||
| 97239ba0f5 | |||
| 51898404db | |||
| 2ca7c03999 | |||
| 86187e4e59 | |||
| 7ebfaaf477 | |||
| 00f8eb7d18 | |||
| 0713ba0213 | |||
| ec81ba2cee | |||
| cc5831358a | |||
| 961e65be4c | |||
| 12b6626624 | |||
| 03fbb0914e | |||
| ed2dc880aa | |||
| 646a7c5e04 | |||
| 11a5a73775 | |||
| 48ec553e0a | |||
| a567243716 | |||
| 4773fdb8d5 | |||
| 7f65471ba1 | |||
| fabd0a90df | |||
| 6093d2bddb | |||
| 42bcda621a | |||
| 215b5d3f27 | |||
| 97d8b86d39 | |||
| 8ba11daf31 | |||
| fe2f79ecad | |||
| 606072ca3a | |||
| 2eef58e23a | |||
| cf4f0789a3 | |||
| 1b78118f51 | |||
| a89a9bcaed | |||
| 8528c33be5 | |||
| d1502e26b1 | |||
| 2f56defffa | |||
| 01af084568 | |||
| cd0e3485c5 | |||
| b540fcbf19 | |||
| 7d29b2af6d | |||
| ad030fe811 | |||
| fae1364f10 | |||
| 93297b5401 | |||
| 511611f994 | |||
| e9e2a0a8e7 | |||
| 1222399926 | |||
| 845c1dc0ea | |||
| 3a21e60e19 | |||
| 9622429180 | |||
| d04b7a4e6a | |||
| 8b369b6049 | |||
| b54a573f61 | |||
| 184326bfde | |||
| b1f3c7d42b | |||
| 53a96e567d | |||
| 20daa7b465 | |||
| 0fa2ef4283 | |||
| e067a0a900 | |||
| 31d8e2f012 | |||
| 84d385ef1a | |||
| fabef9492e | |||
| 92f8386264 | |||
| 1ef62a806b | |||
| f8da3d0754 | |||
| 8165fab326 | |||
| 918c5b4355 | |||
| f930cd4ade | |||
| 3f3940e752 | |||
| 4ef5dd839d | |||
| fd14700eae | |||
| e5548eb6f1 | |||
| a364daf52e | |||
| 7089e6e1b2 | |||
| 0621154902 | |||
| acfb5fec1a | |||
| 1a3df4619e | |||
| 8994775be2 | |||
| 81365dbe6a | |||
| 7ae63b2b66 | |||
| b8dd5911d4 | |||
| 3fc4ffa179 | |||
| b84f6480e7 | |||
| 5dd8de7950 | |||
| 78baaca4a3 | |||
| e597ac7e4b | |||
| 4d5cc93a38 | |||
| ed50132d5e | |||
| fbb084f636 | |||
| d42ef102b2 | |||
| 9673c7d719 | |||
| 9b4623c558 | |||
| b7563e63c1 | |||
| 4d4b5eb007 | |||
| 6c04eec026 | |||
| 1ff2b27edc | |||
| 6bce9ec071 | |||
| 98619cc362 | |||
| 1987d0553c | |||
| 3f78095fe3 | |||
| 245766e1b5 | |||
| 2591653f66 | |||
| d11e90226b | |||
| fb159c17a0 | |||
| f7eb6580cc | |||
| 43720b34ba | |||
| f1f0f002ce | |||
| 86afa21a60 | |||
| 0169acba81 | |||
| 073d950d41 | |||
| 4eaaebd739 | |||
| a9e2106fda | |||
| 8b427989c5 | |||
| f16ce3c69b | |||
| a1b3330e5e | |||
| 3da8f5420b | |||
| 109e896506 | |||
| 8ad38f381e | |||
| 1e32315346 | |||
| ef8c645a6a | |||
| 15ecdf1e57 | |||
| 587b00c93a | |||
| aba2bead27 | |||
| 85ce58f69c | |||
| bb7e00b0eb | |||
| d60b35ebef | |||
| eb60c52224 | |||
| 61828ea2db | |||
| 7e819e14d1 | |||
| 1d9b7d9698 | |||
| 82c05588bc | |||
| 1cddd05bc0 | |||
| 8ad0d1c461 | |||
| a22a75913c | |||
| e797da0ed8 | |||
| 6936c14ed2 | |||
| c626ed5a48 | |||
| d79d05ef5a | |||
| 094a5b8969 | |||
| 12a75f8370 | |||
| 1c14b9aa93 | |||
| 8ea388554a | |||
| 1531c201bb | |||
| ed522c56ae | |||
| 4b454ab2f3 | |||
| f6fbf7226e | |||
| ebd09ab1c8 | |||
| 75cf7edc96 | |||
| 5c8b9c40be | |||
| dcaf9945c8 | |||
| f9426287d5 | |||
| 77281e3ab9 | |||
| 64439ad3d3 | |||
| 9494c1292e | |||
| accf123d49 | |||
| d77598c259 | |||
| 4e6dff52fe | |||
| 92d0aac250 | |||
| 5ef310558a | |||
| 683821b667 | |||
| da4cf71fac | |||
| f81ceae940 | |||
| fa6301a1db | |||
| 442fc425f7 | |||
| ea61588ede | |||
| 7f67aa134a | |||
| d7d1c53c52 | |||
| 18f52f877a | |||
| c13195bd61 | |||
| e40d5b6474 | |||
| 92e5d38755 | |||
| ec7e795ba9 | |||
| af220b2a09 | |||
| 846e30cb38 | |||
| d371d4368b | |||
| 85fc8101e4 | |||
| e2f58a8938 | |||
| 7e6954afd9 | |||
| bed041a1c3 | |||
| f955f720d2 | |||
| b627ac1ca6 | |||
| ac0b218376 | |||
| 04bf5a5349 | |||
| 9cce52a7d9 | |||
| 51e0d87d27 | |||
| d6f7e2e976 | |||
| 0bbf395a62 | |||
| 609d7ceb7a | |||
| b91605864d | |||
| 7857b708c9 | |||
| a0f85538e9 | |||
| c52da4f479 | |||
| af597df7b1 | |||
| 2adb29f4ee | |||
| 2b83944f34 | |||
| 71e80f6df7 | |||
| 0ead11ec6c | |||
| 3e249c5314 | |||
| bacc87945c | |||
| 2cfd428c4c | |||
| c155deedb5 | |||
| 3bc8c407b4 | |||
| c3fae38d5c | |||
| d6ec4213ab | |||
| 150a0de1c4 | |||
| 7cedebc70e | |||
| fe5aca6f0e | |||
| 5d83710fed | |||
| 1431e307ee | |||
| 1934dc3377 | |||
| 8af06d8860 | |||
| 9ea0da95b7 | |||
| d39e2ec21e | |||
| 68c9c9df04 | |||
| 6f7156ef17 | |||
| 50638ff54e | |||
| 8594279b98 | |||
| 0205e01b3c | |||
| 17545c1b7c | |||
| bcf821c06a | |||
| 34376d3490 | |||
| 8ed2308340 | |||
| c73cd58eed | |||
| d78ec570b0 | |||
| dd45f7ce38 | |||
| fb7312cb80 | |||
| dbc28205e8 | |||
| a3ed3bd234 | |||
| 21ecf200b8 | |||
| c8bca08bdc | |||
| 68bd2b81ec | |||
| 09cfb84b94 | |||
| 5c1ffb5636 | |||
| 7f79cc0708 | |||
| b0b4f9068a | |||
| cb9e86750c |
@@ -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"
|
||||
@@ -1,49 +1,22 @@
|
||||
name: Build
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Android Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Setup build
|
||||
run: |
|
||||
cargo install cargo-ndk
|
||||
rustup target add aarch64-linux-android
|
||||
rustup target add armv7-linux-androideabi
|
||||
rustup target add x86_64-linux-android
|
||||
- name: Setup Java build
|
||||
run: |
|
||||
chmod +x android/gradlew
|
||||
echo "${{ secrets.ANDROID_RELEASE_KEYSTORE }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.ANDROID_RELEASE_SECRET }}" --batch release.keystore.asc > android/keystore
|
||||
echo -e "storePassword=${{ secrets.ANDROID_PASS }}\nkeyPassword=${{ secrets.ANDROID_PASS }}\nkeyAlias=grim\nstoreFile=../keystore" > android/keystore.properties
|
||||
- name: Build lib 1/2
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
|
||||
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t arm64-v8a build --profile release-apk
|
||||
- name: Build lib 2/2
|
||||
run: |
|
||||
unset CPPFLAGS && unset CFLAGS && cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --profile release-apk
|
||||
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
|
||||
- name: Build APK
|
||||
working-directory: android
|
||||
run: |
|
||||
./gradlew assembleRelease
|
||||
# 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@v4
|
||||
- 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
|
||||
|
||||
@@ -51,14 +24,20 @@ jobs:
|
||||
name: Windows Build
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -1,248 +1,108 @@
|
||||
# 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:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
# 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:
|
||||
android_release:
|
||||
name: Android Release
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Setup Rust build
|
||||
run: |
|
||||
cargo install cargo-ndk
|
||||
rustup target add aarch64-linux-android
|
||||
rustup target add armv7-linux-androideabi
|
||||
rustup target add x86_64-linux-android
|
||||
- name: Setup Java build
|
||||
run: |
|
||||
chmod +x android/gradlew
|
||||
echo "${{ secrets.ANDROID_RELEASE_KEYSTORE }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.ANDROID_RELEASE_SECRET }}" --batch release.keystore.asc > android/keystore
|
||||
echo -e "storePassword=${{ secrets.ANDROID_PASS }}\nkeyPassword=${{ secrets.ANDROID_PASS }}\nkeyAlias=grim\nstoreFile=../keystore" > android/keystore.properties
|
||||
- name: Build lib ARMv8 1/2
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
|
||||
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t arm64-v8a build --profile release-apk
|
||||
- name: Build lib ARMv8 2/2
|
||||
run: |
|
||||
unset CPPFLAGS && unset CFLAGS && cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --profile release-apk
|
||||
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
|
||||
- name: Build lib ARMv7 1/2
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
|
||||
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t armeabi-v7a build --profile release-apk
|
||||
- name: Build lib ARMv7 2/2
|
||||
run: |
|
||||
unset CPPFLAGS && unset CFLAGS && cargo ndk -t armeabi-v7a -o android/app/src/main/jniLibs build --profile release-apk
|
||||
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
|
||||
- name: Build APK ARM
|
||||
working-directory: android
|
||||
run: |
|
||||
rm -rf app/build
|
||||
./gradlew assembleRelease
|
||||
mv app/build/outputs/apk/release/app-release.apk grim-${{ github.ref_name }}-android.apk
|
||||
rm -rf app/src/main/jniLibs/*
|
||||
- name: Checksum APK ARM
|
||||
working-directory: android
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-android.apk | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-android-sha256sum.txt
|
||||
- name: Build lib x86 1/2
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
|
||||
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t x86_64 build --profile release-apk
|
||||
- name: Build lib x86 2/2
|
||||
run: |
|
||||
unset CPPFLAGS && unset CFLAGS && cargo ndk -t x86_64 -o android/app/src/main/jniLibs build --profile release-apk
|
||||
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
|
||||
- name: Build APK x86
|
||||
working-directory: android
|
||||
run: |
|
||||
rm -rf app/build
|
||||
./gradlew assembleRelease
|
||||
mv app/build/outputs/apk/release/app-release.apk grim-${{ github.ref_name }}-android-x86_64.apk
|
||||
- name: Checksum APK x86
|
||||
working-directory: android
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-android-x86_64.apk | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-android-x86_64-sha256sum.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
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: |
|
||||
android/grim-${{ github.ref_name }}-android.apk
|
||||
android/grim-${{ github.ref_name }}-android-sha256sum.txt
|
||||
android/grim-${{ github.ref_name }}-android-x86_64.apk
|
||||
android/grim-${{ github.ref_name }}-android-x86_64-sha256sum.txt
|
||||
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
|
||||
|
||||
linux_release:
|
||||
name: Linux Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Download appimagetools
|
||||
run: |
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
sudo apt install libfuse2
|
||||
- name: Zig Setup
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.12.1
|
||||
- name: Install cargo-zigbuild
|
||||
run: cargo install cargo-zigbuild
|
||||
- name: Release x86
|
||||
run: cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
||||
- name: Release ARM
|
||||
run: |
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
cargo zigbuild --release --target aarch64-unknown-linux-gnu
|
||||
- name: AppImage x86
|
||||
run: |
|
||||
cp target/x86_64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
|
||||
./appimagetool-x86_64.AppImage linux/Grim.AppDir target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||
- name: Checksum AppImage x86
|
||||
working-directory: target/x86_64-unknown-linux-gnu/release
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-linux-x86_64.AppImage | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-linux-x86_64-appimage-sha256sum.txt
|
||||
- name: AppImage ARM
|
||||
run: |
|
||||
cp target/aarch64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
|
||||
./appimagetool-x86_64.AppImage linux/Grim.AppDir target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm.AppImage
|
||||
- name: Checksum AppImage ARM
|
||||
working-directory: target/aarch64-unknown-linux-gnu/release
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-linux-arm.AppImage | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-linux-arm-appimage-sha256sum.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||
target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64-appimage-sha256sum.txt
|
||||
target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm.AppImage
|
||||
target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm-appimage-sha256sum.txt
|
||||
|
||||
windows_release:
|
||||
name: Windows Release
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build release
|
||||
run: cargo build --release
|
||||
- name: Archive release
|
||||
uses: vimtor/action-zip@v1
|
||||
with:
|
||||
files: target/release/grim.exe
|
||||
dest: target/release/grim-${{ github.ref_name }}-win-x86_64.zip
|
||||
- name: Checksum release
|
||||
working-directory: target/release
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-win-x86_64.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-win-x86_64-sha256sum.txt
|
||||
- name: Install cargo-wix
|
||||
run: cargo install cargo-wix
|
||||
- name: Run cargo-wix
|
||||
run: cargo wix -p grim -o ./target/wix/grim-${{ github.ref_name }}-win-x86_64.msi --nocapture
|
||||
- name: Checksum msi
|
||||
working-directory: target/wix
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-win-x86_64.msi | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-win-x86_64-msi-sha256sum.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
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
|
||||
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: |
|
||||
target/release/grim-${{ github.ref_name }}-win-x86_64.zip
|
||||
target/release/grim-${{ github.ref_name }}-win-x86_64-sha256sum.txt
|
||||
target/wix/grim-${{ github.ref_name }}-win-x86_64.msi
|
||||
target/wix/grim-${{ github.ref_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_release:
|
||||
name: MacOS Release
|
||||
macos:
|
||||
name: macOS universal
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Zig Setup
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.12.1
|
||||
- name: Install cargo-zigbuild
|
||||
run: cargo install cargo-zigbuild
|
||||
- name: Release x86
|
||||
run: |
|
||||
rustup target add x86_64-apple-darwin
|
||||
cargo zigbuild --release --target x86_64-apple-darwin
|
||||
mkdir macos/Grim.app/Contents/MacOS
|
||||
yes | cp -rf target/x86_64-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
|
||||
- name: Archive x86
|
||||
run: |
|
||||
cd macos
|
||||
zip -r grim-${{ github.ref_name }}-macos-x86_64.zip Grim.app
|
||||
mv grim-${{ github.ref_name }}-macos-x86_64.zip ../target/x86_64-apple-darwin/release
|
||||
cd ..
|
||||
- name: Checksum Release x86
|
||||
working-directory: target/x86_64-apple-darwin/release
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-x86_64.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-x86_64-sha256sum.txt
|
||||
- name: Release ARM
|
||||
run: |
|
||||
rustup target add aarch64-apple-darwin
|
||||
cargo zigbuild --release --target aarch64-apple-darwin
|
||||
yes | cp -rf target/aarch64-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
|
||||
- name: Archive ARM
|
||||
run: |
|
||||
cd macos
|
||||
zip -r grim-${{ github.ref_name }}-macos-arm.zip Grim.app
|
||||
mv grim-${{ github.ref_name }}-macos-arm.zip ../target/aarch64-apple-darwin/release
|
||||
cd ..
|
||||
- name: Checksum Release ARM
|
||||
working-directory: target/aarch64-apple-darwin/release
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-arm.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-arm-sha256sum.txt
|
||||
- name: Release Universal
|
||||
run: |
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-apple-darwin
|
||||
cargo zigbuild --release --target universal2-apple-darwin
|
||||
yes | cp -rf target/universal2-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
|
||||
- name: Archive Universal
|
||||
run: |
|
||||
cd macos
|
||||
zip -r grim-${{ github.ref_name }}-macos-universal.zip Grim.app
|
||||
mv grim-${{ github.ref_name }}-macos-universal.zip ../target/universal2-apple-darwin/release
|
||||
cd ..
|
||||
- name: Checksum Release Universal
|
||||
working-directory: target/universal2-apple-darwin/release
|
||||
shell: pwsh
|
||||
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-universal.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-universal-sha256sum.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
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 + package
|
||||
run: |
|
||||
lipo -create -output goblin \
|
||||
target/aarch64-apple-darwin/release/goblin \
|
||||
target/x86_64-apple-darwin/release/goblin
|
||||
zip "goblin-$TAG-macos-universal.zip" goblin
|
||||
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: |
|
||||
target/x86_64-apple-darwin/release/grim-${{ github.ref_name }}-macos-x86_64.zip
|
||||
target/x86_64-apple-darwin/release/grim-${{ github.ref_name }}-macos-x86_64-sha256sum.txt
|
||||
target/aarch64-apple-darwin/release/grim-${{ github.ref_name }}-macos-arm.zip
|
||||
target/aarch64-apple-darwin/release/grim-${{ github.ref_name }}-macos-arm-sha256sum.txt
|
||||
target/universal2-apple-darwin/release/grim-${{ github.ref_name }}-macos-universal.zip
|
||||
target/universal2-apple-darwin/release/grim-${{ github.ref_name }}-macos-universal-sha256sum.txt
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal.zip
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal-sha256sum.txt
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
*.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
|
||||
@@ -13,7 +17,10 @@ android/keystore.properties
|
||||
target
|
||||
.cargo/
|
||||
app/src/main/jniLibs
|
||||
macos/Grim.app/Contents/MacOS/grim
|
||||
macos/cert.pem
|
||||
linux/Grim.AppDir/AppRun
|
||||
.intentionally-empty-file.o
|
||||
linux/Goblin.AppDir/AppRun
|
||||
.intentionally-empty-file.o
|
||||
Cargo.toml-e
|
||||
screenshots/
|
||||
# GRIM-canonical build toolchains fetched by scripts/toolchain.sh
|
||||
.toolchains/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,23 +1,27 @@
|
||||
[package]
|
||||
name = "grim"
|
||||
version = "0.1.2"
|
||||
authors = ["Ardocrat <ardocrat@proton.me>"]
|
||||
description = "Cross-platform GUI for Grin with focus on usability and availability to be used by anyone, anywhere."
|
||||
version = "0.3.6"
|
||||
authors = ["Ardocrat <ardocrat@gri.mw>"]
|
||||
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://github.com/ardocrat/grim"
|
||||
keywords = [ "crypto", "grin", "mimblewimble" ]
|
||||
edition = "2021"
|
||||
repository = "https://code.gri.mw/GUI/grim"
|
||||
keywords = [ "crypto", "grin", "mimblewimble", "nostr" ]
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "grim"
|
||||
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]
|
||||
debug = 1
|
||||
strip = true
|
||||
|
||||
[profile.release-apk]
|
||||
inherits = "release"
|
||||
@@ -28,108 +32,143 @@ codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
log = "0.4.27"
|
||||
|
||||
## node
|
||||
openssl-sys = { version = "0.9.82", features = ["vendored"] }
|
||||
grin_api = "5.3.1"
|
||||
grin_chain = "5.3.1"
|
||||
grin_config = "5.3.1"
|
||||
grin_core = "5.3.1"
|
||||
grin_p2p = "5.3.1"
|
||||
grin_servers = "5.3.1"
|
||||
grin_keychain = "5.3.1"
|
||||
grin_util = "5.3.1"
|
||||
# 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 = "5.3.1"
|
||||
grin_wallet_api = "5.3.1"
|
||||
grin_wallet_libwallet = "5.3.1"
|
||||
grin_wallet_util = "5.3.1"
|
||||
grin_wallet_controller = "5.3.1"
|
||||
# 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.28.1", default-features = false }
|
||||
egui_extras = { version = "0.28.1", features = ["image", "svg"] }
|
||||
rust-i18n = "2.3.1"
|
||||
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
|
||||
backtrace = "0.3"
|
||||
panic-message = "0.3.0"
|
||||
thiserror = "1.0.58"
|
||||
futures = "0.3"
|
||||
dirs = "5.0.1"
|
||||
sys-locale = "0.3.0"
|
||||
chrono = "0.4.31"
|
||||
parking_lot = "0.12.1"
|
||||
lazy_static = "1.4.0"
|
||||
toml = "0.8.2"
|
||||
serde = "1.0.170"
|
||||
local-ip-address = "0.6.1"
|
||||
url = "2.4.0"
|
||||
rand = "0.8.5"
|
||||
serde_derive = "1.0.197"
|
||||
serde_json = "1.0.115"
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
image = "0.25.1"
|
||||
rqrr = "0.7.1"
|
||||
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.0"
|
||||
qrcode = "0.14.1"
|
||||
ur = "0.4.1"
|
||||
gif = "0.13.1"
|
||||
rkv = { version = "0.19.0", features = ["lmdb"] }
|
||||
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"
|
||||
## native-tls (via hyper-tls) uses OpenSSL 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 so every target is self-contained. Inert on
|
||||
## Windows/macOS, which use SChannel / Security.framework instead of OpenSSL.
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
async-std = "1.13.2"
|
||||
uuid = { version = "0.8.2", features = ["v4"] }
|
||||
num-bigint = "0.4.6"
|
||||
|
||||
## tor
|
||||
arti-client = { version = "0.19.0", features = ["pt-client", "static", "onion-service-service", "onion-service-client"] }
|
||||
tor-rtcompat = { version = "0.19.0", features = ["static"] }
|
||||
tor-config = "0.19.0"
|
||||
fs-mistrust = "0.7.9"
|
||||
tor-hsservice = "0.19.0"
|
||||
tor-hsrproxy = "0.19.0"
|
||||
tor-keymgr = "0.19.0"
|
||||
tor-llcrypto = "0.19.0"
|
||||
tor-hscrypto = "0.19.0"
|
||||
arti-hyper = "0.19.0"
|
||||
sha2 = "0.10.0"
|
||||
ed25519-dalek = "2.1.1"
|
||||
curve25519-dalek = "4.1.2"
|
||||
hyper = { version = "0.14.28", features = ["full"] }
|
||||
hyper-tls = "0.5.0"
|
||||
tls-api = "0.9.0"
|
||||
tls-api-native-tls = "0.9.0"
|
||||
## 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-old = { version = "0.2", features = ["full"], package = "tokio" }
|
||||
tokio-util-old = { version = "0.2", features = ["codec"], package = "tokio-util" }
|
||||
|
||||
[target.'cfg(all(not(target_os = "windows"), not(target_os = "android")))'.dependencies]
|
||||
eye = { version = "0.5.0", default-features = false }
|
||||
[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.4", default-features = false, features = ["input-msmf"] }
|
||||
nokhwa = { version = "0.10.10", default-features = false, features = ["input-msmf"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tls-api-openssl = "0.9.0"
|
||||
openpnp_capture_sys = "0.4.0"
|
||||
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.29.15" }
|
||||
eframe = { version = "0.28.1", features = ["wgpu", "glow"] }
|
||||
winit = { version = "0.30.12" }
|
||||
wgpu = { version = "27.0.1" }
|
||||
eframe = { version = "0.33.2", features = ["wgpu"] }
|
||||
arboard = "3.2.0"
|
||||
rfd = "0.14.1"
|
||||
dark-light = "1.1.1"
|
||||
rfd = "0.17.2"
|
||||
interprocess = { version = "2.2.1", features = ["tokio"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.13.1"
|
||||
android_logger = "0.15.0"
|
||||
jni = "0.21.1"
|
||||
android-activity = { version = "0.6.0", features = ["game-activity"] }
|
||||
wgpu = "0.20.1"
|
||||
winit = { version = "0.29.15", features = ["android-game-activity"] }
|
||||
eframe = { version = "0.28.1", features = ["wgpu", "android-game-activity"] }
|
||||
winit = { version = "0.30.12", features = ["android-game-activity"] }
|
||||
eframe = { version = "0.33.2", default-features = false, features = ["glow", "android-game-activity"] }
|
||||
|
||||
[patch.crates-io]
|
||||
### patch grin store
|
||||
#grin_store = { path = "../grin-store" }
|
||||
### fix cross-compilation support for macos
|
||||
openpnp_capture_sys = { git = "https://github.com/ardocrat/openpnp-capture-rs", branch = "cross_compilation_support" }
|
||||
[build-dependencies]
|
||||
built = "0.8.0"
|
||||
|
||||
[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"
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -1,38 +1,73 @@
|
||||
# <img height="22" src="https://github.com/ardocrat/grim/blob/master/android/app/src/main/ic_launcher-playstore.png?raw=true"> Grim <img height="20" src="https://github.com/mimblewimble/site/blob/master/assets/images/grin-logo.png?raw=true"> <img height="20" src="https://github.com/ardocrat/grim/blob/master/img/logo.png?raw=true">
|
||||
Cross-platform GUI for [GRiN ツ](https://grin.mw) in [Rust](https://www.rust-lang.org/)
|
||||
for maximum compatibility with original [Mimblewimble](https://github.com/mimblewimble/grin) implementation.
|
||||
Initially supported platforms are Linux, Mac, Windows, limited Android and possible web support with help of [egui](https://github.com/emilk/egui) - immediate mode GUI library in pure Rust.
|
||||
<p align="center">
|
||||
<img src="Goblin-Banner.png" alt="Goblin" width="680"/>
|
||||
</p>
|
||||
|
||||
Named by the character [Grim](http://harrypotter.wikia.com/wiki/Grim) - the shape of a large, black, menacing, spectral giant dog.
|
||||
# Goblin
|
||||
|
||||

|
||||
Goblin is a private, Cash App-style 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.
|
||||
|
||||
## Build instructions
|
||||
### Install Rust
|
||||
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.
|
||||
|
||||
Follow instructions on [Windows](https://forge.rust-lang.org/infra/other-installation-methods.html).
|
||||
## What it does
|
||||
|
||||
`curl https://sh.rustup.rs -sSf | sh`
|
||||
- **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.
|
||||
- **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` (and hosted avatar) comes from the goblin.st identity service.
|
||||
- **Private by construction** — GRIN's address-less, confidential chain; every relay and HTTP request (relays, NIP-05 lookups, price, avatars) routed through the [Nym mixnet](https://nym.com) via a bundled `nym-socks5-client` sidecar, so nothing touches the clear net; 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).
|
||||
|
||||
### Desktop
|
||||
|
||||
To build and run application go to project directory and run:
|
||||
## 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)
|
||||
|
||||
```
|
||||
git submodule update --init --recursive
|
||||
cargo build --release
|
||||
./target/release/grim
|
||||
./target/release/goblin
|
||||
```
|
||||
|
||||
Goblin routes all of its traffic over the [Nym mixnet](https://nym.com) using a `nym-socks5-client` sidecar that runs alongside the wallet and exposes a local SOCKS5 proxy on `127.0.0.1:1080`. Ship the `nym-socks5-client` binary next to the `goblin` executable (or point `GOBLIN_NYM_BIN` at it), and set the network requester it routes through via `GOBLIN_NYM_PROVIDER` (or bake it into `NETWORK_REQUESTER` in `src/nym/sidecar.rs`). If a SOCKS5 endpoint is already listening on `127.0.0.1:1080`, Goblin reuses it.
|
||||
|
||||
### Android
|
||||
#### Set up the environment
|
||||
|
||||
Install Android SDK / NDK / Platform Tools for your OS according to this [FAQ](https://github.com/codepath/android_guides/wiki/installing-android-sdk-tools).
|
||||
Install the Android SDK / NDK, then from the repo root:
|
||||
|
||||
#### Build the project
|
||||
Run Android emulator or connect a real device. Command `adb devices` should show at least one device.
|
||||
In the root of the repo run `./scripts/build_run_android.sh debug|release v7|v8`, where is `v7`, `v8` - device CPU architecture.
|
||||
```
|
||||
./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` + avatar 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/transfer/release, and a hardened avatar pipeline (magic-byte sniffing, bounded decode, full re-encode to a clean 256×256 PNG). The wallet is fully usable — and fully anonymous — without it.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -2,36 +2,49 @@ plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
|
||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
|
||||
android {
|
||||
compileSdk 33
|
||||
ndkVersion '26.0.10792818'
|
||||
compileSdk = 36
|
||||
ndkVersion '29.0.14206865'
|
||||
buildToolsVersion = '36.1.0'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "mw.gri.android"
|
||||
applicationId "st.goblin.wallet"
|
||||
minSdk 24
|
||||
targetSdk 33
|
||||
versionCode 3
|
||||
versionName "0.1.2"
|
||||
targetSdk 36
|
||||
versionCode 5
|
||||
versionName "0.3.6"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
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'
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signedRelease {
|
||||
initWith release
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
debug {
|
||||
minifyEnabled false
|
||||
@@ -39,21 +52,63 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
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.6.1'
|
||||
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.2.3'
|
||||
implementation 'androidx.camera:camera-camera2:1.2.3'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.2.3'
|
||||
}
|
||||
|
||||
implementation 'androidx.camera:camera-core:1.5.1'
|
||||
implementation 'androidx.camera:camera-camera2:1.5.1'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.5.1'
|
||||
}
|
||||
@@ -1,52 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
>
|
||||
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
|
||||
<uses-permission android:name="android.permission.EXPAND_STATUS_BAR" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
|
||||
|
||||
<application
|
||||
android:hardwareAccelerated="true"
|
||||
android:largeHeap="true"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Grim"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Main">
|
||||
android:hardwareAccelerated="true"
|
||||
android:largeHeap="true"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Goblin"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/Theme.Main"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:extractNativeLibs="true"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<receiver android:name=".NotificationActionsReceiver"/>
|
||||
|
||||
<provider
|
||||
android:name=".FileProvider"
|
||||
android:authorities="mw.gri.android.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
android:name=".FileProvider"
|
||||
android:authorities="st.goblin.wallet.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/paths" />
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:launchMode="singleTask"
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:exported="true">
|
||||
android:launchMode="singleTask"
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode|density|locale|layoutDirection|fontScale|colorMode"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:scheme="http" tools:ignore="AppLinkUrlError">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
<data android:pathPattern=".*\\.slatepack" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="android.app.lib_name" android:value="grim" />
|
||||
</activity>
|
||||
<service android:name=".BackgroundService" android:stopWithTask="true" />
|
||||
|
||||
<service
|
||||
android:name=".BackgroundService"
|
||||
android:stopWithTask="true"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -2,13 +2,13 @@ package mw.gri.android;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.*;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.*;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -16,7 +16,7 @@ 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());
|
||||
@@ -31,26 +31,6 @@ public class BackgroundService extends Service {
|
||||
|
||||
public static final String ACTION_START_NODE = "start_node";
|
||||
public static final String ACTION_STOP_NODE = "stop_node";
|
||||
public static final String ACTION_EXIT = "exit";
|
||||
public static final String ACTION_REFRESH = "refresh";
|
||||
public static final String ACTION_STOP = "stop";
|
||||
|
||||
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
|
||||
@SuppressLint("RestrictedApi")
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent.getAction().equals(ACTION_STOP)) {
|
||||
mStopped = true;
|
||||
// Remove actions buttons.
|
||||
mNotificationBuilder.mActions.clear();
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
manager.notify(NOTIFICATION_ID, mNotificationBuilder.build());
|
||||
} else {
|
||||
mHandler.removeCallbacks(mUpdateSyncStatus);
|
||||
mHandler.post(mUpdateSyncStatus);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final Runnable mUpdateSyncStatus = new Runnable() {
|
||||
@SuppressLint("RestrictedApi")
|
||||
@@ -101,18 +81,6 @@ public class BackgroundService extends Service {
|
||||
.getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
|
||||
mNotificationBuilder.addAction(R.drawable.ic_stop, getStopText(), i);
|
||||
}
|
||||
|
||||
// Set up a button to exit from the app.
|
||||
if (canStart || canStop) {
|
||||
Intent exitIntent = new Intent(BackgroundService.this, NotificationActionsReceiver.class);
|
||||
if (Build.VERSION.SDK_INT > 25) {
|
||||
exitIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID);
|
||||
}
|
||||
exitIntent.setAction(ACTION_EXIT);
|
||||
PendingIntent i = PendingIntent
|
||||
.getBroadcast(BackgroundService.this, 1, exitIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
|
||||
mNotificationBuilder.addAction(R.drawable.ic_close, getExitText(), i);
|
||||
}
|
||||
}
|
||||
|
||||
// Update notification.
|
||||
@@ -152,13 +120,17 @@ public class BackgroundService extends Service {
|
||||
// Show notification with sync status.
|
||||
Intent i = getPackageManager().getLaunchIntentForPackage(this.getPackageName());
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE);
|
||||
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);
|
||||
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.
|
||||
@@ -166,9 +138,6 @@ public class BackgroundService extends Service {
|
||||
|
||||
// Update sync status at notification.
|
||||
mHandler.post(mUpdateSyncStatus);
|
||||
|
||||
// Register receiver to refresh notifications by intent.
|
||||
registerReceiver(mReceiver, new IntentFilter(ACTION_REFRESH));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -199,7 +168,6 @@ public class BackgroundService extends Service {
|
||||
|
||||
// Stop updating the notification.
|
||||
mHandler.removeCallbacks(mUpdateSyncStatus);
|
||||
unregisterReceiver(mReceiver);
|
||||
clearNotification();
|
||||
|
||||
// Remove service from foreground state.
|
||||
@@ -222,12 +190,12 @@ public class BackgroundService extends Service {
|
||||
}
|
||||
|
||||
// Start the service.
|
||||
public static void start(Context context) {
|
||||
if (!isServiceRunning(context)) {
|
||||
public static void start(Context c) {
|
||||
if (!isServiceRunning(c)) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(new Intent(context, BackgroundService.class));
|
||||
ContextCompat.startForegroundService(c, new Intent(c, BackgroundService.class));
|
||||
} else {
|
||||
context.startService(new Intent(context, BackgroundService.class));
|
||||
c.startService(new Intent(c, BackgroundService.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,9 +234,6 @@ public class BackgroundService extends Service {
|
||||
// Check if stop node is possible.
|
||||
private native boolean canStopNode();
|
||||
|
||||
// Get exit text for notification.
|
||||
private native String getExitText();
|
||||
|
||||
// Check if app from the app is needed after node stop.
|
||||
private native boolean exitAppAfterNodeStop();
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,15 @@ import android.content.*;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Configuration;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.*;
|
||||
import android.os.Process;
|
||||
import android.provider.Settings;
|
||||
import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.util.Size;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -28,9 +28,11 @@ 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;
|
||||
|
||||
@@ -50,15 +52,13 @@ public class MainActivity extends GameActivity {
|
||||
private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context ctx, Intent i) {
|
||||
if (i.getAction().equals(STOP_APP_ACTION)) {
|
||||
onExit();
|
||||
Process.killProcess(Process.myPid());
|
||||
if (Objects.equals(i.getAction(), STOP_APP_ACTION)) {
|
||||
exit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final ImageAnalysis mImageAnalysis = new ImageAnalysis.Builder()
|
||||
.setTargetResolution(new Size(640, 480))
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build();
|
||||
|
||||
@@ -67,21 +67,30 @@ public class MainActivity extends GameActivity {
|
||||
private ExecutorService mCameraExecutor = null;
|
||||
private boolean mUseBackCamera = true;
|
||||
|
||||
private ActivityResultLauncher<Intent> mFilePickResultLauncher = null;
|
||||
private ActivityResultLauncher<Intent> mFilePickResult = null;
|
||||
private ActivityResultLauncher<Intent> mOpenFilePermissionsResult = 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) {
|
||||
if (savedInstanceState == null && getExternalCacheDir() != null) {
|
||||
Utils.deleteDirectoryContent(new File(getExternalCacheDir().getPath()), false);
|
||||
}
|
||||
|
||||
// Setup environment variables for native code.
|
||||
try {
|
||||
Os.setenv("HOME", getExternalFilesDir("").getPath(), true);
|
||||
Os.setenv("XDG_CACHE_HOME", getExternalCacheDir().getPath(), true);
|
||||
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);
|
||||
}
|
||||
@@ -89,17 +98,30 @@ public class MainActivity extends GameActivity {
|
||||
super.onCreate(null);
|
||||
|
||||
// Register receiver to finish activity from the BackgroundService.
|
||||
registerReceiver(mBroadcastReceiver, new IntentFilter(STOP_APP_ACTION));
|
||||
ContextCompat.registerReceiver(this, mBroadcastReceiver, new IntentFilter(STOP_APP_ACTION), ContextCompat.RECEIVER_NOT_EXPORTED);
|
||||
|
||||
// Register file pick result launcher.
|
||||
mFilePickResultLauncher = registerForActivityResult(
|
||||
// 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) {
|
||||
if (data != null && data.getData() != null) {
|
||||
Uri uri = data.getData();
|
||||
String name = "pick" + Utils.getFileExtension(uri, this);
|
||||
File file = new File(getExternalCacheDir(), name);
|
||||
@@ -107,11 +129,13 @@ public class MainActivity extends GameActivity {
|
||||
OutputStream os = new FileOutputStream(file)) {
|
||||
byte[] buffer = new byte[1024];
|
||||
int length;
|
||||
while ((length = is.read(buffer)) > 0) {
|
||||
while (true) {
|
||||
assert is != null;
|
||||
if (!((length = is.read(buffer)) > 0)) break;
|
||||
os.write(buffer, 0, length);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
Log.e("grim", e.toString());
|
||||
}
|
||||
path = file.getPath();
|
||||
}
|
||||
@@ -124,7 +148,7 @@ public class MainActivity extends GameActivity {
|
||||
// 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) -> {
|
||||
// Setup cutouts values.
|
||||
// Get display cutouts.
|
||||
DisplayCutoutCompat dc = insets.getDisplayCutout();
|
||||
int cutoutTop = 0;
|
||||
int cutoutRight = 0;
|
||||
@@ -140,7 +164,7 @@ public class MainActivity extends GameActivity {
|
||||
// Get display insets.
|
||||
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
|
||||
// Setup values to pass into native code.
|
||||
// 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);
|
||||
@@ -166,8 +190,65 @@ public class MainActivity extends GameActivity {
|
||||
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);
|
||||
@@ -184,78 +265,128 @@ public class MainActivity extends GameActivity {
|
||||
if (results.length != 0 && results[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
switch (requestCode) {
|
||||
case NOTIFICATIONS_PERMISSION_CODE: {
|
||||
// Start notification service.
|
||||
BackgroundService.start(this);
|
||||
return;
|
||||
}
|
||||
case CAMERA_PERMISSION_CODE: {
|
||||
// Start camera.
|
||||
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) {
|
||||
// To support non-english input.
|
||||
if (event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
|
||||
if (!event.getCharacters().isEmpty()) {
|
||||
onInput(event.getCharacters());
|
||||
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;
|
||||
}
|
||||
// Pass any other input values into native code.
|
||||
} 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) {
|
||||
onInput(String.valueOf((char)event.getUnicodeChar()));
|
||||
onTextInput(String.valueOf((char)event.getUnicodeChar()));
|
||||
return false;
|
||||
}
|
||||
return super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
// Provide last entered character from soft keyboard into native code.
|
||||
public native void onInput(String character);
|
||||
|
||||
// Implemented into native code to handle display insets change.
|
||||
native void onDisplayInsets(int[] cutouts);
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
onBack();
|
||||
return true;
|
||||
}
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
// Implemented into native code to handle key code BACK event.
|
||||
// Pass back navigation event into native code.
|
||||
public native void onBack();
|
||||
|
||||
// Actions on app exit.
|
||||
private void onExit() {
|
||||
unregisterReceiver(mBroadcastReceiver);
|
||||
BackgroundService.stop(this);
|
||||
// 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() {
|
||||
onExit();
|
||||
unregisterReceiver(mBroadcastReceiver);
|
||||
|
||||
// Kill process after 3 seconds if app was terminated from recent apps to prevent app hanging.
|
||||
new Thread(() -> {
|
||||
try {
|
||||
onTermination();
|
||||
Thread.sleep(3000);
|
||||
Process.killProcess(Process.myPid());
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}).start();
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Destroy an app and kill process.
|
||||
super.onDestroy();
|
||||
Process.killProcess(Process.myPid());
|
||||
}
|
||||
|
||||
// Notify native code to stop activity (e.g. node) if app was terminated from recent apps.
|
||||
@@ -271,45 +402,33 @@ public class MainActivity extends GameActivity {
|
||||
// Called from native code to get text from clipboard.
|
||||
public String pasteText() {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
String text;
|
||||
ClipDescription desc = clipboard.getPrimaryClipDescription();
|
||||
ClipData data = clipboard.getPrimaryClip();
|
||||
String text = "";
|
||||
if (!(clipboard.hasPrimaryClip())) {
|
||||
text = "";
|
||||
} else if (!(clipboard.getPrimaryClipDescription().hasMimeType(MIMETYPE_TEXT_PLAIN))
|
||||
&& !(clipboard.getPrimaryClipDescription().hasMimeType(MIMETYPE_TEXT_HTML))) {
|
||||
} else if (desc != null && (!(desc.hasMimeType(MIMETYPE_TEXT_PLAIN))
|
||||
&& !(desc.hasMimeType(MIMETYPE_TEXT_HTML)))) {
|
||||
text = "";
|
||||
} else {
|
||||
ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0);
|
||||
} else if (data != null) {
|
||||
ClipData.Item item = data.getItemAt(0);
|
||||
text = item.getText().toString();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Called from native code to show keyboard.
|
||||
public void showKeyboard() {
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.showSoftInput(getWindow().getDecorView(), InputMethodManager.SHOW_IMPLICIT);
|
||||
}
|
||||
|
||||
// Called from native code to hide keyboard.
|
||||
public void hideKeyboard() {
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.hideSoftInputFromWindow(getWindow().getDecorView().getWindowToken(), 0);
|
||||
}
|
||||
|
||||
// Called from native code to start camera.
|
||||
public void startCamera() {
|
||||
// Check permissions.
|
||||
String notificationsPermission = Manifest.permission.CAMERA;
|
||||
if (checkSelfPermission(notificationsPermission) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(new String[] { notificationsPermission }, CAMERA_PERMISSION_CODE);
|
||||
} else {
|
||||
// Start .
|
||||
if (mCameraProviderFuture == null) {
|
||||
mCameraProviderFuture = ProcessCameraProvider.getInstance(this);
|
||||
mCameraProviderFuture.addListener(() -> {
|
||||
try {
|
||||
mCameraProvider = mCameraProviderFuture.get();
|
||||
// Launch camera.
|
||||
// Start camera.
|
||||
openCamera();
|
||||
} catch (Exception e) {
|
||||
View content = findViewById(android.R.id.content);
|
||||
@@ -381,14 +500,52 @@ public class MainActivity extends GameActivity {
|
||||
// Pass image from camera into native code.
|
||||
public native void onCameraImage(byte[] buff, int rotation);
|
||||
|
||||
// Called from native code to share image from provided path.
|
||||
public void shareImage(String path) {
|
||||
// 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, "mw.gri.android.fileprovider", file);
|
||||
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("image/*");
|
||||
startActivity(Intent.createChooser(intent, "Share image"));
|
||||
intent.setType("text/*");
|
||||
startActivity(Intent.createChooser(intent, "Share data"));
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
@@ -400,9 +557,9 @@ public class MainActivity extends GameActivity {
|
||||
// Called from native code to pick the file.
|
||||
public void pickFile() {
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.setType("*/*");
|
||||
intent.setType("text/*");
|
||||
try {
|
||||
mFilePickResultLauncher.launch(Intent.createChooser(intent, "Pick file"));
|
||||
mFilePickResult.launch(Intent.createChooser(intent, "Pick file"));
|
||||
} catch (android.content.ActivityNotFoundException ex) {
|
||||
onFilePick("");
|
||||
}
|
||||
|
||||
@@ -4,23 +4,18 @@ 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 (a.equals(BackgroundService.ACTION_START_NODE)) {
|
||||
if (Objects.equals(a, BackgroundService.ACTION_START_NODE)) {
|
||||
startNode();
|
||||
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH));
|
||||
} else if (a.equals(BackgroundService.ACTION_STOP_NODE)) {
|
||||
} else if (Objects.equals(a, BackgroundService.ACTION_STOP_NODE)) {
|
||||
stopNode();
|
||||
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH));
|
||||
} else {
|
||||
if (isNodeRunning()) {
|
||||
stopNodeToExit();
|
||||
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH));
|
||||
} else {
|
||||
context.sendBroadcast(new Intent(MainActivity.STOP_APP_ACTION));
|
||||
}
|
||||
stopNodeToExit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +25,4 @@ public class NotificationActionsReceiver extends BroadcastReceiver {
|
||||
native void stopNode();
|
||||
// Stop node and exit from the app.
|
||||
native void stopNodeToExit();
|
||||
// Check if node is running.
|
||||
native boolean isNodeRunning();
|
||||
}
|
||||
|
||||
@@ -153,4 +153,4 @@ public class Utils {
|
||||
String fileType = context.getContentResolver().getType(uri);
|
||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(fileType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 30 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FCEF03</color>
|
||||
<color name="ic_launcher_background">#FFD60A</color>
|
||||
</resources>
|
||||
@@ -3,6 +3,7 @@
|
||||
<item name="android:statusBarColor">@color/yellow</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:navigationBarColor">@color/black</item>
|
||||
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="o_mr1">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-cache-path name="images" path="images/" />
|
||||
<external-cache-path name="share" path="share/" />
|
||||
</paths>
|
||||
@@ -1,10 +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.1.1' apply false
|
||||
id 'com.android.library' version '8.1.1' apply false
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
id 'com.android.application' version '8.10.0' apply false
|
||||
id 'com.android.library' version '8.10.0' apply false
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
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
|
||||
@@ -19,5 +19,4 @@ android.useAndroidX=true
|
||||
# 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.defaults.buildfeatures.buildconfig=true
|
||||
android.nonFinalResIds=false
|
||||
@@ -1,6 +1,6 @@
|
||||
#Mon May 02 15:39:12 BST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
|
||||
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
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
//rootProject.name = "Rust Template"
|
||||
include ':app'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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.
|
||||
}
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="600.000000pt" height="601.000000pt" viewBox="0 0 600.000000 601.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,601.000000) scale(0.050000,-0.050000)"
|
||||
fill="#ffffff" stroke="none">
|
||||
<path d="M195 11784 c-515 -1551 98 -2966 1520 -3514 171 -66 171 -72 2 -72
|
||||
-415 0 -893 215 -1273 572 -178 167 -181 163 -77 -83 478 -1130 1770 -1734
|
||||
2963 -1384 283 83 309 101 420 292 376 648 1038 1116 1763 1245 l143 26 100
|
||||
202 c887 1780 -911 3344 -3076 2675 l-170 -52 220 -14 c480 -32 818 -114 1118
|
||||
-273 258 -137 548 -414 624 -597 l32 -76 -87 76 c-435 375 -938 524 -1557 461
|
||||
-273 -28 -340 -39 -740 -120 -893 -182 -1449 24 -1756 650 l-98 200 -71 -214z"/>
|
||||
<path d="M9720 10053 c0 -146 -256 -556 -441 -705 -381 -308 -766 -380 -1559
|
||||
-290 -1209 137 -2026 -255 -2519 -1208 -78 -151 -82 -156 -72 -77 18 140 128
|
||||
450 210 592 43 74 73 135 67 135 -25 0 -397 -184 -512 -253 -966 -580 -1594
|
||||
-1674 -1594 -2777 0 -104 -4 -190 -10 -190 -5 0 -65 43 -132 95 -650 503
|
||||
-1118 775 -1606 935 -290 95 -302 94 -242 -15 52 -95 166 -372 191 -465 9 -33
|
||||
34 -121 56 -195 48 -161 120 -508 194 -935 118 -684 437 -1371 795 -1715 185
|
||||
-178 324 -239 594 -262 144 -13 150 -15 147 -63 -1 -27 -14 -174 -28 -326 -83
|
||||
-902 258 -1568 1037 -2024 139 -81 161 -80 127 4 -37 96 -85 369 -96 546 l-10
|
||||
170 46 -60 c328 -431 869 -753 1497 -891 351 -77 1285 -67 1426 15 9 5 -104
|
||||
50 -250 98 -649 217 -1341 668 -1680 1096 -81 102 -88 147 -11 78 157 -142
|
||||
653 -406 925 -493 783 -249 1542 -242 2332 20 l274 91 106 -83 c298 -236 798
|
||||
-328 1259 -231 197 42 196 38 32 169 -156 124 -344 356 -443 546 l-60 116 70
|
||||
52 c544 408 783 906 627 1300 l-41 102 170 138 c442 355 584 624 813 1537 186
|
||||
742 257 952 452 1328 l118 227 -145 -14 c-693 -68 -1425 -411 -1729 -810 -99
|
||||
-130 -112 -127 -165 44 -54 175 -217 511 -308 636 -64 88 -70 91 -224 117
|
||||
-314 52 -637 184 -956 390 -260 168 -509 436 -280 302 127 -74 302 -160 430
|
||||
-211 97 -38 146 -55 348 -118 335 -105 983 -129 1280 -47 961 264 1554 1048
|
||||
1729 2286 28 199 45 685 23 677 -9 -4 -73 -77 -141 -163 -650 -810 -1594
|
||||
-1147 -2451 -873 -251 80 -246 76 -171 121 395 240 638 767 578 1253 -25 209
|
||||
-77 395 -77 278z m-682 -4962 c306 -158 601 -1396 416 -1747 -118 -224 -283
|
||||
-345 -526 -387 -455 -78 -577 227 -456 1143 96 725 320 1118 566 991z m-3115
|
||||
-156 c371 -172 699 -815 799 -1565 34 -251 19 -307 -108 -422 -575 -519 -1624
|
||||
-162 -1931 657 -265 709 593 1630 1240 1330z m5261 -120 c20 -377 -135 -912
|
||||
-290 -995 -70 -38 -72 -35 -157 230 l-64 200 -24 -90 c-50 -192 -144 -340
|
||||
-215 -340 -111 0 -316 804 -228 893 53 53 200 -130 226 -283 l14 -80 36 105
|
||||
c100 293 222 333 332 109 l54 -110 35 105 c89 260 271 427 281 256z m-8491
|
||||
-284 l75 -230 52 160 c89 272 210 336 295 154 126 -268 210 -757 142 -825 -44
|
||||
-44 -163 120 -247 340 -12 33 -19 24 -39 -50 -89 -330 -286 -346 -374 -30
|
||||
l-22 80 -35 -125 c-54 -196 -223 -428 -275 -377 -37 37 -29 448 11 608 70 276
|
||||
219 524 314 524 17 0 56 -87 103 -229z m4164 -2698 c137 -311 883 -373 1408
|
||||
-116 162 78 171 64 42 -61 -317 -305 -854 -408 -1271 -244 -223 87 -516 338
|
||||
-516 442 0 53 140 267 212 324 l58 46 14 -152 c8 -84 31 -191 53 -239z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1 @@
|
||||
goblin.png
|
||||
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Name=Goblin
|
||||
Exec=goblin
|
||||
Icon=goblin
|
||||
Type=Application
|
||||
Categories=Finance
|
||||
MimeType=application/x-slatepack;text/plain;
|
||||
|
After Width: | Height: | Size: 37 KiB |
@@ -1 +0,0 @@
|
||||
grim.png
|
||||
@@ -1,6 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=Grim
|
||||
Exec=grim
|
||||
Icon=grim
|
||||
Type=Application
|
||||
Categories=Finance
|
||||
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,27 +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.
|
||||
|
||||
case $2 in
|
||||
x86_64|arm)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: release_linux.sh [version] [platform]\n - platform: 'x86_64', 'arm'" >&2
|
||||
exit 1
|
||||
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
|
||||
|
||||
# Setup build directory
|
||||
BASEDIR=$(cd $(dirname $0) && pwd)
|
||||
cd ${BASEDIR}
|
||||
cd ..
|
||||
# Repo root (this script lives in linux/).
|
||||
BASEDIR=$(cd "$(dirname "$0")" && pwd)
|
||||
cd "${BASEDIR}/.."
|
||||
|
||||
# Setup platform argument
|
||||
[[ $2 == "x86_64" ]] && arch+=(x86_64-unknown-linux-gnu)
|
||||
[[ $2 == "arm" ]] && arch+=(aarch64-unknown-linux-gnu)
|
||||
# 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
|
||||
|
||||
# Start release build with zig linker for cross-compilation
|
||||
cargo install cargo-zigbuild
|
||||
cargo zigbuild --release --target ${arch}
|
||||
rustup target add "${arch}"
|
||||
command -v cargo-zigbuild >/dev/null || cargo install cargo-zigbuild
|
||||
|
||||
# Create AppImage with https://github.com/AppImage/appimagetool
|
||||
cp target/${arch}/release/grim linux/Grim.AppDir/AppRun
|
||||
rm target/${arch}/release/*.AppImage
|
||||
appimagetool linux/Grim.AppDir target/${arch}/release/grim-v$1-linux-$2.AppImage
|
||||
# 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}"
|
||||
|
||||
@@ -25,9 +25,20 @@ 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
|
||||
@@ -82,6 +93,7 @@ wallets:
|
||||
tx_canceled: Abgebrochen
|
||||
tx_cancelling: Abbrechen
|
||||
tx_finalizing: Finalisierung
|
||||
tx_posting: Buchungsvorgang
|
||||
tx_confirmed: Bestätigt
|
||||
txs: Transaktionen
|
||||
tx: Transaktion
|
||||
@@ -125,6 +137,12 @@ wallets:
|
||||
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
|
||||
@@ -137,7 +155,7 @@ transport:
|
||||
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 %{amount} ツ über Tor'
|
||||
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.
|
||||
@@ -282,13 +300,59 @@ network_settings:
|
||||
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_peers_desc: Peer-Daten zurücksetzen. Verwenden Sie diese Funktion nur, wenn es Probleme beim finden von Peers gibt.
|
||||
reset_peers: Peers zurücksetzten
|
||||
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
|
||||
confirmation: Bestätigung
|
||||
add: Hinzufügen
|
||||
modal_exit:
|
||||
description: Sind Sie sicher, dass Sie die Anwendung beenden wollen?
|
||||
exit: Schließen
|
||||
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: '/'
|
||||
@@ -25,9 +25,20 @@ 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
|
||||
@@ -38,7 +49,7 @@ wallets:
|
||||
add: Add wallet
|
||||
name: 'Name:'
|
||||
pass: 'Password:'
|
||||
pass_empty: Enter password from the wallet
|
||||
pass_empty: Enter the wallet password
|
||||
current_pass: 'Current password:'
|
||||
new_pass: 'New password:'
|
||||
min_tx_conf_count: 'Minimum amount of confirmations for transactions:'
|
||||
@@ -82,6 +93,7 @@ wallets:
|
||||
tx_canceled: Canceled
|
||||
tx_cancelling: Cancelling
|
||||
tx_finalizing: Finalizing
|
||||
tx_posting: Posting
|
||||
tx_confirmed: Confirmed
|
||||
txs: Transactions
|
||||
tx: Transaction
|
||||
@@ -125,6 +137,12 @@ wallets:
|
||||
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
|
||||
@@ -137,7 +155,7 @@ transport:
|
||||
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 %{amount} ツ over Tor'
|
||||
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.
|
||||
@@ -162,7 +180,7 @@ network:
|
||||
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 Grim application at system settings of your phone. This is necessary operation for correct work of application in the background.
|
||||
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
|
||||
@@ -282,13 +300,59 @@ network_settings:
|
||||
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_peers_desc: Reset peers data. Use it with a caution only if there are problems with finding peers.
|
||||
reset_peers: Reset peers
|
||||
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
|
||||
confirmation: Confirmation
|
||||
add: Add
|
||||
modal_exit:
|
||||
description: Are you sure you want to quit the application?
|
||||
exit: Exit
|
||||
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: /
|
||||
@@ -25,9 +25,20 @@ 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
|
||||
@@ -82,6 +93,7 @@ wallets:
|
||||
tx_canceled: Annulé
|
||||
tx_cancelling: Annulation
|
||||
tx_finalizing: Finalisation
|
||||
tx_posting: Publication
|
||||
tx_confirmed: Confirmé
|
||||
txs: Transactions
|
||||
tx: Transaction
|
||||
@@ -125,6 +137,12 @@ wallets:
|
||||
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
|
||||
@@ -137,7 +155,7 @@ transport:
|
||||
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 de %{amount} ツ via Tor'
|
||||
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.
|
||||
@@ -282,13 +300,59 @@ network_settings:
|
||||
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_peers_desc: Réinitialiser les données des pairs. Utilisez-le avec précaution uniquement en cas de problèmes pour trouver des pairs.
|
||||
reset_peers: Réinitialiser les pairs
|
||||
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
|
||||
confirmation: Confirmation
|
||||
add: Ajouter
|
||||
modal_exit:
|
||||
description: "Êtes-vous sûr de vouloir quitter l'application ?"
|
||||
exit: Quitter
|
||||
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: /
|
||||
@@ -25,9 +25,20 @@ 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: Ожидает завершения
|
||||
@@ -82,6 +93,7 @@ wallets:
|
||||
tx_canceled: Отменено
|
||||
tx_cancelling: Отмена
|
||||
tx_finalizing: Завершение
|
||||
tx_posting: Публикация
|
||||
tx_confirmed: Подтверждено
|
||||
txs: Транзакции
|
||||
tx: Транзакция
|
||||
@@ -125,6 +137,12 @@ wallets:
|
||||
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
|
||||
@@ -137,7 +155,7 @@ transport:
|
||||
incorrect_addr_err: 'Введённый адрес неверен:'
|
||||
tor_send_error: Во время отправки через Tor произошла ошибка, убедитесь, что получатель находится онлайн, транзакция была отменена.
|
||||
tor_autorun_desc: Запускать ли Tor сервис при открытии кошелька для синхронного получения транзакций.
|
||||
tor_sending: 'Отправка %{amount} ツ через Tor'
|
||||
tor_sending: Отправка через Tor
|
||||
tor_settings: Настройки Tor
|
||||
bridges: Мосты
|
||||
bridges_desc: Настройте мосты для обхода цензуры сети Tor, если обычное соединение не работает.
|
||||
@@ -282,13 +300,59 @@ network_settings:
|
||||
ban_window_desc: Решение о запрете принимается узлом, основываясь на корректности данных полученных от пира.
|
||||
max_inbound_count: 'Максимальное количество входящих подключений пиров:'
|
||||
max_outbound_count: 'Максимальное количество исходящих подключений к пирам:'
|
||||
reset_peers_desc: Сбросить данные пиров. Используйте с осторожностью, только при наличии проблем с поиском пиров.
|
||||
reset_peers: Сбросить пиры
|
||||
reset_data_desc: Сбросить данные узла. Используйте с осторожностью, только при наличии проблем с синхронизацией.
|
||||
reset_data: Сброс данных
|
||||
modal:
|
||||
cancel: Отмена
|
||||
save: Сохранить
|
||||
confirmation: Подтверждение
|
||||
add: Добавить
|
||||
modal_exit:
|
||||
description: Вы уверены, что хотите выйти из приложения?
|
||||
exit: Выход
|
||||
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: ё
|
||||
@@ -25,9 +25,20 @@ 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
|
||||
@@ -82,6 +93,7 @@ wallets:
|
||||
tx_canceled: Iptal edildi
|
||||
tx_cancelling: Iptal ediliyor
|
||||
tx_finalizing: Islem tamamlaniyor
|
||||
tx_posting: Islem kaydetme
|
||||
tx_confirmed: Onaylandi
|
||||
txs: Islemler
|
||||
tx: Islem
|
||||
@@ -125,6 +137,12 @@ wallets:
|
||||
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
|
||||
@@ -137,7 +155,7 @@ transport:
|
||||
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 %{amount} ツ gonderiliyor.'
|
||||
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.
|
||||
@@ -282,13 +300,59 @@ network_settings:
|
||||
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_peers_desc: Peers verilerini sifirlayin. Yalnizca Peers bulma konusunda sorun yasiyorsaniz dikkatli kullanin.
|
||||
reset_peers: Peers Resetle
|
||||
reset_data_desc: Node verisini sifirlama. Sadece senkronizasyonda sorun varsa dikkatli kullanin.
|
||||
reset_data: Verileri sifirlama
|
||||
modal:
|
||||
cancel: Iptal
|
||||
save: Kaydet
|
||||
confirmation: Onay
|
||||
add: Ekle
|
||||
modal_exit:
|
||||
description: Uygulamadan cikmak için exit, emin misiniz?
|
||||
exit: Exit
|
||||
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: /
|
||||
@@ -0,0 +1,358 @@
|
||||
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: 钱包
|
||||
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: /
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Goblin</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>goblin</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>mw.gri.macos</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Goblin</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.6</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Goblin needs an access to your camera to scan QR code.</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Apple SimpleText document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.apple.traditional-mac-plain-text</string>
|
||||
</array>
|
||||
<key>NSDocumentClass</key>
|
||||
<string>Document</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Unknown document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>NSDocumentClass</key>
|
||||
<string>Document</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.finance</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>2024</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,2 @@
|
||||
!.gitignore
|
||||
grim
|
||||
@@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Grim</string>
|
||||
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>grim</string>
|
||||
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>mw.gri.macos</string>
|
||||
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
|
||||
<key>CFBundleName</key>
|
||||
<string>Grim</string>
|
||||
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.finance</string>
|
||||
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>2024</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,22 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case $2 in
|
||||
case $1 in
|
||||
x86_64|arm|universal)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: release_macos.sh [version] [platform]\n - platform: 'x86_64', 'arm', 'universal'" >&2
|
||||
echo "Usage: release_macos.sh [platform] [version]\n - platform: 'x86_64', 'arm', 'universal'" >&2
|
||||
exit 1
|
||||
esac
|
||||
|
||||
if [[ ! -v SDKROOT ]]; then
|
||||
if [[ "$OSTYPE" != "darwin"* ]]; then
|
||||
if [ -z ${SDKROOT+x} ]; then
|
||||
echo "MacOS SDKROOT is not set"
|
||||
exit 1
|
||||
elif [[ -z "SDKROOT" ]]; then
|
||||
echo "MacOS SDKROOT is set to the empty string"
|
||||
exit 1
|
||||
else
|
||||
else
|
||||
echo "Use MacOS SDK: ${SDKROOT}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Setup build directory
|
||||
@@ -25,31 +24,25 @@ 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
|
||||
|
||||
rm -rf target/x86_64-apple-darwin
|
||||
rm -rf target/aarch64-apple-darwin
|
||||
|
||||
[[ $2 == "x86_64" ]] && arch+=(x86_64-apple-darwin)
|
||||
[[ $2 == "arm" ]] && arch+=(aarch64-apple-darwin)
|
||||
[[ $2 == "universal" ]] && arch+=(universal2-apple-darwin)
|
||||
|
||||
# Start release build with zig linker for cross-compilation
|
||||
# zig 0.12+ required
|
||||
[[ $1 == "universal" ]]; arch+=(universal2-apple-darwin)
|
||||
cargo install cargo-zigbuild
|
||||
cargo zigbuild --release --target ${arch}
|
||||
rm -rf .intentionally-empty-file.o
|
||||
mkdir macos/Grim.app/Contents/MacOS
|
||||
yes | cp -rf target/${arch}/release/grim macos/Grim.app/Contents/MacOS
|
||||
|
||||
### Sign .app resources on change:
|
||||
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/Grim.app
|
||||
#rcodesign sign --pem-file cert.pem macos/Goblin.app
|
||||
|
||||
# Create release package
|
||||
FILE_NAME=grim-v$1-macos-$2.zip
|
||||
rm -rf target/${arch}/release/${FILE_NAME}
|
||||
FILE_NAME=goblin-v$2-macos-$1.zip
|
||||
cd macos
|
||||
zip -r ${FILE_NAME} Grim.app
|
||||
zip -r ${FILE_NAME} Goblin.app
|
||||
mv ${FILE_NAME} ../target/${arch}/release
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
hard_tabs = true
|
||||
edition = "2024"
|
||||
@@ -1,81 +1,135 @@
|
||||
#!/bin/bash
|
||||
|
||||
usage="Usage: build_run_android.sh [type] [platform]\n - type: 'debug', 'release'\n - platform: 'v7', 'v8'"
|
||||
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
|
||||
debug|release)
|
||||
build|lib|release)
|
||||
;;
|
||||
*)
|
||||
printf "$usage"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
case $2 in
|
||||
v7|v8)
|
||||
;;
|
||||
*)
|
||||
printf "$usage"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
# Setup build directory
|
||||
BASEDIR=$(cd $(dirname $0) && pwd)
|
||||
cd ${BASEDIR}
|
||||
cd ..
|
||||
|
||||
# Setup release argument
|
||||
type=$1
|
||||
[[ ${type} == "release" ]] && release_param="--profile release-apk"
|
||||
|
||||
# Setup platform argument
|
||||
[[ $2 == "v7" ]] && arch+=(armeabi-v7a)
|
||||
[[ $2 == "v8" ]] && arch+=(arm64-v8a)
|
||||
|
||||
# Setup platform path
|
||||
[[ $2 == "v7" ]] && platform+=(armv7-linux-androideabi)
|
||||
[[ $2 == "v8" ]] && platform+=(aarch64-linux-android)
|
||||
|
||||
# Install platform
|
||||
[[ $2 == "v7" ]] && rustup target install armv7-linux-androideabi
|
||||
[[ $2 == "v8" ]] && rustup target install aarch64-linux-android
|
||||
|
||||
# Build native code
|
||||
cargo install cargo-ndk
|
||||
|
||||
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
|
||||
|
||||
# temp fix for https://stackoverflow.com/questions/57193895/error-use-of-undeclared-identifier-pthread-mutex-robust-cargo-build-liblmdb-s
|
||||
success=0
|
||||
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0"
|
||||
cargo ndk -t ${arch} build ${release_param}
|
||||
unset CPPFLAGS && unset CFLAGS
|
||||
cargo ndk -t ${arch} -o android/app/src/main/jniLibs build ${release_param}
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
success=1
|
||||
if [[ $1 == "build" ]]; then
|
||||
case $2 in
|
||||
v7|v8|x86)
|
||||
;;
|
||||
*)
|
||||
printf "$usage"
|
||||
exit 1
|
||||
esac
|
||||
fi
|
||||
|
||||
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
|
||||
# Setup build directory
|
||||
BASEDIR=$(cd "$(dirname "$0")" && pwd)
|
||||
cd "${BASEDIR}" || exit 1
|
||||
cd ..
|
||||
|
||||
# Build Android application and launch at all connected devices
|
||||
if [ $success -eq 1 ]
|
||||
then
|
||||
cd android
|
||||
# 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
|
||||
|
||||
# Setup gradle argument
|
||||
[[ $1 == "release" ]] && gradle_param+=(assembleRelease)
|
||||
[[ $1 == "debug" ]] && gradle_param+=(build)
|
||||
# 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
|
||||
[[ flavor == "" ]] && flavor="local"
|
||||
cd android || exit 1
|
||||
./gradlew clean
|
||||
./gradlew ${gradle_param}
|
||||
# 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
|
||||
|
||||
# Setup apk path
|
||||
[[ $1 == "release" ]] && apk_path+=(app/build/outputs/apk/release/app-release.apk)
|
||||
[[ $1 == "debug" ]] && apk_path+=(app/build/outputs/apk/debug/app-debug.apk)
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
@@ -1,25 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
case $1 in
|
||||
debug|release)
|
||||
debug|build)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: build_run.sh [type] where is type is 'debug' or 'release'" >&2
|
||||
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}
|
||||
BASEDIR=$(cd "$(dirname $0)" && pwd)
|
||||
cd "${BASEDIR}" || return
|
||||
cd ..
|
||||
|
||||
# Build application
|
||||
type=$1
|
||||
[[ ${type} == "release" ]] && release_param+=(--release)
|
||||
cargo build ${release_param[@]}
|
||||
[[ ${type} == "build" ]] && release_param+=(--release)
|
||||
cargo --config profile.release.incremental=true build "${release_param[@]}"
|
||||
|
||||
# Start application
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
./target/${type}/grim
|
||||
fi
|
||||
path=${type}
|
||||
[[ ${type} == "build" ]] && path="release"
|
||||
./target/"${path}"/grim
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate all Goblin app icons from img/goblin-icon.png (app icon)
|
||||
# and img/goblin-mask.png (black mascot art on transparency).
|
||||
# Requires ImageMagick (magick).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
ICON=img/goblin-icon.png
|
||||
MASK=img/goblin-mask.png
|
||||
RES=android/app/src/main/res
|
||||
|
||||
# Desktop window icon + in-app embeds.
|
||||
magick "$ICON" -resize 256x256 img/icon.png
|
||||
magick "$ICON" -resize 512x512 img/goblin-icon-512.png
|
||||
magick "$MASK" -channel RGB -fill white -colorize 100 img/goblin-mask-white.png
|
||||
magick img/goblin-mask-white.png -resize 128x128 img/goblin-mask-128.png
|
||||
magick img/goblin-mask-white.png -resize 64x64 img/goblin-mask-64.png
|
||||
|
||||
# Android launcher icons.
|
||||
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 occupies ~52% of the adaptive canvas (safe zone is 66%)
|
||||
art=$(( fg * 52 / 100 ))
|
||||
magick "$ICON" -resize ${s}x${s} "$RES/mipmap-$d/ic_launcher.png"
|
||||
magick "$ICON" -resize ${s}x${s} "$RES/mipmap-$d/ic_launcher_round.png"
|
||||
magick "$MASK" -resize ${art}x${art} -background none \
|
||||
-gravity center -extent ${fg}x${fg} "$RES/mipmap-$d/ic_launcher_foreground.png"
|
||||
done
|
||||
|
||||
echo "icons generated"
|
||||
@@ -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"
|
||||
@@ -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/<Package Id="[^\"]*"/<Package Id="'"$(uuidgen)"'"/g' wix/main.wxs
|
||||
|
||||
# Update Android version in build.gradle
|
||||
sed -i'.bak' -e 's/versionName [0-9a-zA-Z -_]*/versionName "'"$VERSION_NEXT"'"/' android/app/build.gradle
|
||||
rm -f android/app/build.gradle.bak
|
||||
|
||||
# Update version in Cargo.toml
|
||||
sed -i'.bak' -e "s/^version = .*/version = \"$VERSION_NEXT\"/" Cargo.toml
|
||||
rm -f Cargo.toml.bak
|
||||
|
||||
# Update Cargo.lock as this changes when
|
||||
# updating the version in your manifest
|
||||
cargo update -p grim
|
||||
|
||||
# Commit the changes
|
||||
git add .
|
||||
git commit -m "build: version $VERSION_NEXT"
|
||||
|
||||
# ==================================
|
||||
# Create git tag for new version
|
||||
# ==================================
|
||||
|
||||
# Create a tag and push to master branch
|
||||
#git tag "v$VERSION_NEXT" master
|
||||
#git push origin master --follow-tags
|
||||
@@ -12,408 +12,463 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use egui::epaint::RectShape;
|
||||
use egui::{
|
||||
Align, Context, CornerRadius, CursorIcon, LayerId, Layout, Modifiers, Order, ResizeDirection,
|
||||
Stroke, StrokeKind, UiBuilder, ViewportCommand,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use egui::{Align, Context, CursorIcon, Layout, Modifiers, Rect, ResizeDirection, Rounding, Stroke, ViewportCommand};
|
||||
use egui::epaint::{RectShape};
|
||||
use egui::os::OperatingSystem;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::{ARROWS_IN, ARROWS_OUT, CARET_DOWN, MOON, SUN, X};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::views::{Content, TitlePanel, View};
|
||||
use crate::gui::views::types::ContentContainer;
|
||||
use crate::gui::views::{Content, KeyboardContent, Modal, TitlePanel, View};
|
||||
|
||||
lazy_static! {
|
||||
/// State to check if platform Back button was pressed.
|
||||
static ref BACK_BUTTON_PRESSED: AtomicBool = AtomicBool::new(false);
|
||||
/// State to check if platform Back button was pressed.
|
||||
static ref BACK_BUTTON_PRESSED: AtomicBool = AtomicBool::new(false);
|
||||
}
|
||||
|
||||
/// Implements ui entry point and contains platform-specific callbacks.
|
||||
pub struct App<Platform> {
|
||||
/// Platform specific callbacks handler.
|
||||
pub(crate) platform: Platform,
|
||||
/// Handles platform-specific functionality.
|
||||
pub platform: Platform,
|
||||
|
||||
/// Main ui content.
|
||||
content: Content,
|
||||
/// Main content.
|
||||
content: Content,
|
||||
|
||||
/// Last window resize direction.
|
||||
resize_direction: Option<ResizeDirection>
|
||||
/// Last window resize direction.
|
||||
resize_direction: Option<ResizeDirection>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
pub fn new(platform: Platform) -> Self {
|
||||
Self { platform, content: Content::default(), resize_direction: None }
|
||||
}
|
||||
pub fn new(platform: Platform) -> Self {
|
||||
Self {
|
||||
platform,
|
||||
content: Content::default(),
|
||||
resize_direction: None,
|
||||
first_draw: true,
|
||||
status_bar_white: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw application content.
|
||||
pub fn ui(&mut self, ctx: &Context) {
|
||||
// Handle Esc keyboard key event and platform Back button key event.
|
||||
let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed);
|
||||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::Escape)) || back_pressed {
|
||||
self.content.on_back();
|
||||
if back_pressed {
|
||||
BACK_BUTTON_PRESSED.store(false, Ordering::Relaxed);
|
||||
}
|
||||
// Request repaint to update previous content.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
|
||||
// Handle Close event (on desktop).
|
||||
if ctx.input(|i| i.viewport().close_requested()) {
|
||||
if !self.content.exit_allowed {
|
||||
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
|
||||
Content::show_exit_modal();
|
||||
} else {
|
||||
ctx.input(|i| {
|
||||
if let Some(rect) = i.viewport().inner_rect {
|
||||
AppConfig::save_window_size(rect.width(), rect.height());
|
||||
}
|
||||
if let Some(rect) = i.viewport().outer_rect {
|
||||
AppConfig::save_window_pos(rect.left(), rect.top());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Draw application content.
|
||||
pub fn ui(&mut self, ctx: &Context) {
|
||||
if self.first_draw {
|
||||
self.on_first_draw(ctx);
|
||||
self.first_draw = false;
|
||||
}
|
||||
|
||||
// Show main content with custom frame on desktop.
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
..Default::default()
|
||||
})
|
||||
.show(ctx, |ui| {
|
||||
let is_mac_os = OperatingSystem::from_target_os() == OperatingSystem::Mac;
|
||||
if View::is_desktop() && !is_mac_os {
|
||||
self.desktop_window_ui(ui);
|
||||
} else {
|
||||
if is_mac_os {
|
||||
self.window_title_ui(ui);
|
||||
ui.add_space(-1.0);
|
||||
}
|
||||
self.content.ui(ui, &self.platform);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// Draw custom resizeable window content.
|
||||
fn desktop_window_ui(&mut self, ui: &mut egui::Ui) {
|
||||
let is_fullscreen = ui.ctx().input(|i| {
|
||||
i.viewport().fullscreen.unwrap_or(false)
|
||||
});
|
||||
// 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();
|
||||
}
|
||||
|
||||
let title_stroke_rect = {
|
||||
let mut rect = ui.max_rect();
|
||||
if !is_fullscreen {
|
||||
rect = rect.shrink(Content::WINDOW_FRAME_MARGIN);
|
||||
}
|
||||
rect.max.y = if !is_fullscreen {
|
||||
Content::WINDOW_FRAME_MARGIN
|
||||
} else {
|
||||
0.0
|
||||
} + Content::WINDOW_TITLE_HEIGHT + TitlePanel::DEFAULT_HEIGHT + 0.5;
|
||||
rect
|
||||
};
|
||||
let title_stroke = RectShape {
|
||||
rect: title_stroke_rect,
|
||||
rounding: Rounding {
|
||||
nw: 8.0,
|
||||
ne: 8.0,
|
||||
sw: 0.0,
|
||||
se: 0.0,
|
||||
},
|
||||
fill: Colors::yellow(),
|
||||
stroke: Stroke {
|
||||
width: 1.0,
|
||||
color: egui::Color32::from_gray(200)
|
||||
},
|
||||
blur_width: 0.0,
|
||||
fill_texture_id: Default::default(),
|
||||
uv: Rect::ZERO
|
||||
};
|
||||
// Draw title stroke.
|
||||
ui.painter().add(title_stroke);
|
||||
// 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let content_stroke_rect = {
|
||||
let mut rect = ui.max_rect();
|
||||
if !is_fullscreen {
|
||||
rect = rect.shrink(Content::WINDOW_FRAME_MARGIN);
|
||||
}
|
||||
let top = Content::WINDOW_TITLE_HEIGHT + TitlePanel::DEFAULT_HEIGHT + 0.5;
|
||||
rect.min += egui::vec2(0.0, top);
|
||||
rect
|
||||
};
|
||||
let content_stroke = RectShape {
|
||||
rect: content_stroke_rect,
|
||||
rounding: Rounding::ZERO,
|
||||
fill: Colors::fill(),
|
||||
stroke: Stroke {
|
||||
width: 1.0,
|
||||
color: Colors::stroke()
|
||||
},
|
||||
blur_width: 0.0,
|
||||
fill_texture_id: Default::default(),
|
||||
uv: Rect::ZERO
|
||||
};
|
||||
// Draw content stroke.
|
||||
ui.painter().add(content_stroke);
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw window content.
|
||||
let mut content_rect = ui.max_rect();
|
||||
if !is_fullscreen {
|
||||
content_rect = content_rect.shrink(Content::WINDOW_FRAME_MARGIN);
|
||||
}
|
||||
ui.allocate_ui_at_rect(content_rect, |ui| {
|
||||
self.window_title_ui(ui);
|
||||
self.window_content(ui);
|
||||
});
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// 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 window content for desktop.
|
||||
fn window_content(&mut self, ui: &mut egui::Ui) {
|
||||
let content_rect = {
|
||||
let mut rect = ui.max_rect();
|
||||
rect.min.y += Content::WINDOW_TITLE_HEIGHT;
|
||||
rect
|
||||
};
|
||||
// Draw main content.
|
||||
let mut content_ui = ui.child_ui(content_rect, *ui.layout(), None);
|
||||
self.content.ui(&mut content_ui, &self.platform);
|
||||
}
|
||||
/// 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);
|
||||
|
||||
/// Draw custom window title content.
|
||||
fn window_title_ui(&self, ui: &mut egui::Ui) {
|
||||
let content_rect = ui.max_rect();
|
||||
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);
|
||||
|
||||
let title_rect = {
|
||||
let mut rect = content_rect;
|
||||
rect.max.y = rect.min.y + Content::WINDOW_TITLE_HEIGHT;
|
||||
rect
|
||||
};
|
||||
// Draw title panel background.
|
||||
Self::title_panel_bg(ui, true);
|
||||
|
||||
let is_fullscreen = ui.ctx().input(|i| {
|
||||
i.viewport().fullscreen.unwrap_or(false)
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
let window_title_bg = RectShape {
|
||||
rect: title_rect,
|
||||
rounding: if is_fullscreen {
|
||||
Rounding::ZERO
|
||||
} else {
|
||||
Rounding {
|
||||
nw: 8.0,
|
||||
ne: 8.0,
|
||||
sw: 0.0,
|
||||
se: 0.0,
|
||||
}
|
||||
},
|
||||
fill: Colors::yellow_dark(),
|
||||
stroke: Stroke::NONE,
|
||||
blur_width: 0.0,
|
||||
fill_texture_id: Default::default(),
|
||||
uv: Rect::ZERO
|
||||
};
|
||||
// Draw title background.
|
||||
ui.painter().add(window_title_bg);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
let painter = ui.painter();
|
||||
/// 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);
|
||||
}
|
||||
|
||||
let interact_rect = {
|
||||
let mut rect = title_rect;
|
||||
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::click_and_drag(),
|
||||
);
|
||||
/// 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
|
||||
};
|
||||
|
||||
// Paint the title.
|
||||
let dual_wallets_panel =
|
||||
ui.available_width() >= (Content::SIDE_PANEL_WIDTH * 3.0) + View::get_right_inset();
|
||||
let wallet_panel_opened = self.content.wallets.wallet_panel_opened();
|
||||
let hide_app_name = if dual_wallets_panel {
|
||||
!wallet_panel_opened || (AppConfig::show_wallets_at_dual_panel() &&
|
||||
self.content.wallets.showing_wallet() && !self.content.wallets.creating_wallet())
|
||||
} else if Content::is_dual_panel_mode(ui) {
|
||||
!wallet_panel_opened
|
||||
} else {
|
||||
!Content::is_network_panel_open() && !wallet_panel_opened
|
||||
};
|
||||
let title_text = if hide_app_name {
|
||||
"ツ".to_string()
|
||||
} else {
|
||||
format!("Grim {}", crate::VERSION)
|
||||
};
|
||||
painter.text(
|
||||
title_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
title_text,
|
||||
egui::FontId::proportional(15.0),
|
||||
Colors::title(true),
|
||||
);
|
||||
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);
|
||||
|
||||
// Interact with the window title (drag to move window):
|
||||
if !is_fullscreen && title_resp.double_clicked() {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen));
|
||||
}
|
||||
let painter = ui.painter();
|
||||
|
||||
if !is_fullscreen && title_resp.drag_started_by(egui::PointerButton::Primary) {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
ui.allocate_ui_at_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, |_| {
|
||||
Content::show_exit_modal();
|
||||
});
|
||||
// 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),
|
||||
);
|
||||
|
||||
// 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));
|
||||
});
|
||||
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 button to minimize window.
|
||||
View::title_button_small(ui, CARET_DOWN, |ui| {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true));
|
||||
});
|
||||
// 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 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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
// Draw button to minimize window.
|
||||
View::title_button_small(ui, CARET_DOWN, |ui| {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true));
|
||||
});
|
||||
|
||||
/// Setup window resize area.
|
||||
fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) {
|
||||
let mut rect = ui.max_rect();
|
||||
// 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 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 window resize area.
|
||||
fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) {
|
||||
let mut rect = ui.max_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;
|
||||
}
|
||||
}
|
||||
// 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<Platform: PlatformCallbacks> eframe::App for App<Platform> {
|
||||
fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) {
|
||||
self.ui(ctx);
|
||||
}
|
||||
fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) {
|
||||
ctx.plugin_or_default::<egui_async::EguiAsyncPlugin>();
|
||||
self.ui(ctx);
|
||||
}
|
||||
|
||||
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
|
||||
if View::is_desktop() {
|
||||
let is_mac_os = OperatingSystem::from_target_os() == OperatingSystem::Mac;
|
||||
if is_mac_os {
|
||||
Colors::fill().to_normalized_gamma_f32()
|
||||
} else {
|
||||
egui::Rgba::TRANSPARENT.to_array()
|
||||
}
|
||||
} else {
|
||||
Colors::fill().to_normalized_gamma_f32()
|
||||
}
|
||||
}
|
||||
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)]
|
||||
#[no_mangle]
|
||||
#[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,
|
||||
_env: jni::JNIEnv,
|
||||
_class: jni::objects::JObject,
|
||||
_activity: jni::objects::JObject,
|
||||
) {
|
||||
BACK_BUTTON_PRESSED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
BACK_BUTTON_PRESSED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 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.
|
||||
@@ -12,242 +13,155 @@
|
||||
// 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::AppConfig;
|
||||
use crate::gui::theme;
|
||||
|
||||
/// Provides colors values based on current theme.
|
||||
/// Provides color values based on the current theme tokens.
|
||||
pub struct Colors;
|
||||
|
||||
const WHITE: Color32 = Color32::from_gray(253);
|
||||
const BLACK: Color32 = Color32::from_gray(12);
|
||||
|
||||
const SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(100);
|
||||
const DARK_SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(170);
|
||||
|
||||
const GOLD: Color32 = Color32::from_rgb(255, 215, 0);
|
||||
const INK: Color32 = Color32::from_rgb(0x0E, 0x0E, 0x0C);
|
||||
const PAPER: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xF7);
|
||||
|
||||
const YELLOW: Color32 = Color32::from_rgb(254, 241, 2);
|
||||
const YELLOW_DARK: Color32 = Color32::from_rgb(239, 229, 3);
|
||||
|
||||
const GREEN: Color32 = Color32::from_rgb(0, 0x64, 0);
|
||||
|
||||
const RED: Color32 = Color32::from_rgb(0x8B, 0, 0);
|
||||
|
||||
const BLUE: Color32 = Color32::from_rgb(0, 0x66, 0xE4);
|
||||
|
||||
const FILL: Color32 = Color32::from_gray(244);
|
||||
const FILL_DARK: Color32 = Color32::from_gray(24);
|
||||
|
||||
const FILL_DEEP: Color32 = Color32::from_gray(238);
|
||||
const FILL_DEEP_DARK: Color32 = Color32::from_gray(18);
|
||||
|
||||
const TEXT: Color32 = Color32::from_gray(80);
|
||||
const TEXT_DARK: Color32 = Color32::from_gray(185);
|
||||
|
||||
const CHECKBOX: Color32 = Color32::from_gray(100);
|
||||
const CHECKBOX_DARK: Color32 = Color32::from_gray(175);
|
||||
|
||||
const TEXT_BUTTON: Color32 = Color32::from_gray(70);
|
||||
const TEXT_BUTTON_DARK: Color32 = Color32::from_gray(195);
|
||||
|
||||
const TITLE: Color32 = Color32::from_gray(60);
|
||||
const TITLE_DARK: Color32 = Color32::from_gray(205);
|
||||
|
||||
const BUTTON: Color32 = Color32::from_gray(249);
|
||||
const BUTTON_DARK: Color32 = Color32::from_gray(16);
|
||||
|
||||
const GRAY: Color32 = Color32::from_gray(120);
|
||||
const GRAY_DARK: Color32 = Color32::from_gray(145);
|
||||
|
||||
const STROKE: Color32 = Color32::from_gray(200);
|
||||
const STROKE_DARK: Color32 = Color32::from_gray(50);
|
||||
|
||||
const INACTIVE_TEXT: Color32 = Color32::from_gray(150);
|
||||
const INACTIVE_TEXT_DARK: Color32 = Color32::from_gray(115);
|
||||
|
||||
const ITEM_BUTTON: Color32 = Color32::from_gray(90);
|
||||
const ITEM_BUTTON_DARK: Color32 = Color32::from_gray(175);
|
||||
|
||||
const ITEM_STROKE: Color32 = Color32::from_gray(220);
|
||||
const ITEM_STROKE_DARK: Color32 = Color32::from_gray(40);
|
||||
|
||||
const ITEM_HOVER: Color32 = Color32::from_gray(205);
|
||||
const ITEM_HOVER_DARK: Color32 = Color32::from_gray(48);
|
||||
|
||||
/// Check if dark theme should be used.
|
||||
fn use_dark() -> bool {
|
||||
AppConfig::dark_theme().unwrap_or(false)
|
||||
fn dark_base() -> bool {
|
||||
theme::tokens().dark_base
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0);
|
||||
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);
|
||||
|
||||
pub fn white_or_black(black_in_white: bool) -> Color32 {
|
||||
if use_dark() {
|
||||
if black_in_white {
|
||||
WHITE
|
||||
} else {
|
||||
BLACK
|
||||
}
|
||||
} else {
|
||||
if black_in_white {
|
||||
BLACK
|
||||
} else {
|
||||
WHITE
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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 use_dark() {
|
||||
DARK_SEMI_TRANSPARENT
|
||||
} else {
|
||||
SEMI_TRANSPARENT
|
||||
}
|
||||
}
|
||||
pub fn semi_transparent() -> Color32 {
|
||||
if dark_base() {
|
||||
DARK_SEMI_TRANSPARENT
|
||||
} else {
|
||||
SEMI_TRANSPARENT
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gold() -> Color32 {
|
||||
if use_dark() {
|
||||
GOLD.gamma_multiply(0.9)
|
||||
} else {
|
||||
GOLD
|
||||
}
|
||||
}
|
||||
pub fn gold() -> Color32 {
|
||||
theme::tokens().accent
|
||||
}
|
||||
|
||||
pub fn yellow() -> Color32 {
|
||||
YELLOW
|
||||
}
|
||||
pub fn gold_dark() -> Color32 {
|
||||
theme::tokens().accent_dark
|
||||
}
|
||||
|
||||
pub fn yellow_dark() -> Color32 {
|
||||
YELLOW_DARK
|
||||
}
|
||||
pub fn yellow() -> Color32 {
|
||||
theme::tokens().accent
|
||||
}
|
||||
|
||||
pub fn green() -> Color32 {
|
||||
if use_dark() {
|
||||
GREEN.gamma_multiply(1.3)
|
||||
} else {
|
||||
GREEN
|
||||
}
|
||||
}
|
||||
pub fn yellow_dark() -> Color32 {
|
||||
theme::tokens().accent_dark
|
||||
}
|
||||
|
||||
pub fn red() -> Color32 {
|
||||
if use_dark() {
|
||||
RED.gamma_multiply(1.3)
|
||||
} else {
|
||||
RED
|
||||
}
|
||||
}
|
||||
/// Ink color to draw on top of accent fills.
|
||||
pub fn accent_ink() -> Color32 {
|
||||
theme::tokens().accent_ink
|
||||
}
|
||||
|
||||
pub fn blue() -> Color32 {
|
||||
if use_dark() {
|
||||
BLUE.gamma_multiply(1.3)
|
||||
} else {
|
||||
BLUE
|
||||
}
|
||||
}
|
||||
pub fn green() -> Color32 {
|
||||
theme::tokens().pos
|
||||
}
|
||||
|
||||
pub fn fill() -> Color32 {
|
||||
if use_dark() {
|
||||
FILL_DARK
|
||||
} else {
|
||||
FILL
|
||||
}
|
||||
}
|
||||
pub fn red() -> Color32 {
|
||||
theme::tokens().neg
|
||||
}
|
||||
|
||||
pub fn fill_deep() -> Color32 {
|
||||
if use_dark() {
|
||||
FILL_DEEP_DARK
|
||||
} else {
|
||||
FILL_DEEP
|
||||
}
|
||||
}
|
||||
pub fn blue() -> Color32 {
|
||||
if dark_base() {
|
||||
Color32::from_rgb(0x7B, 0xA7, 0xFF)
|
||||
} else {
|
||||
Color32::from_rgb(0x0E, 0x62, 0xD0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkbox() -> Color32 {
|
||||
if use_dark() {
|
||||
CHECKBOX_DARK
|
||||
} else {
|
||||
CHECKBOX
|
||||
}
|
||||
}
|
||||
pub fn fill() -> Color32 {
|
||||
theme::tokens().bg
|
||||
}
|
||||
|
||||
pub fn text(always_light: bool) -> Color32 {
|
||||
if use_dark() && !always_light {
|
||||
TEXT_DARK
|
||||
} else {
|
||||
TEXT
|
||||
}
|
||||
}
|
||||
pub fn fill_deep() -> Color32 {
|
||||
theme::tokens().surface2
|
||||
}
|
||||
|
||||
pub fn text_button() -> Color32 {
|
||||
if use_dark() {
|
||||
TEXT_BUTTON_DARK
|
||||
} else {
|
||||
TEXT_BUTTON
|
||||
}
|
||||
}
|
||||
pub fn fill_lite() -> Color32 {
|
||||
theme::tokens().surface
|
||||
}
|
||||
|
||||
pub fn title(always_light: bool) -> Color32 {
|
||||
if use_dark() && !always_light {
|
||||
TITLE_DARK
|
||||
} else {
|
||||
TITLE
|
||||
}
|
||||
}
|
||||
pub fn checkbox() -> Color32 {
|
||||
theme::tokens().text_dim
|
||||
}
|
||||
|
||||
pub fn button() -> Color32 {
|
||||
if use_dark() {
|
||||
BUTTON_DARK
|
||||
} else {
|
||||
BUTTON
|
||||
}
|
||||
}
|
||||
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 gray() -> Color32 {
|
||||
if use_dark() {
|
||||
GRAY_DARK
|
||||
} else {
|
||||
GRAY
|
||||
}
|
||||
}
|
||||
pub fn text_button() -> Color32 {
|
||||
theme::tokens().text
|
||||
}
|
||||
|
||||
pub fn stroke() -> Color32 {
|
||||
if use_dark() {
|
||||
STROKE_DARK
|
||||
} else {
|
||||
STROKE
|
||||
}
|
||||
}
|
||||
pub fn title(always_light: bool) -> Color32 {
|
||||
if always_light {
|
||||
INK
|
||||
} else {
|
||||
theme::tokens().text
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inactive_text() -> Color32 {
|
||||
if use_dark() {
|
||||
INACTIVE_TEXT_DARK
|
||||
} else {
|
||||
INACTIVE_TEXT
|
||||
}
|
||||
}
|
||||
pub fn gray() -> Color32 {
|
||||
theme::tokens().text_mute
|
||||
}
|
||||
|
||||
pub fn item_button() -> Color32 {
|
||||
if use_dark() {
|
||||
ITEM_BUTTON_DARK
|
||||
} else {
|
||||
ITEM_BUTTON
|
||||
}
|
||||
}
|
||||
pub fn stroke() -> Color32 {
|
||||
theme::tokens().line
|
||||
}
|
||||
|
||||
pub fn item_stroke() -> Color32 {
|
||||
if use_dark() {
|
||||
ITEM_STROKE_DARK
|
||||
} else {
|
||||
ITEM_STROKE
|
||||
}
|
||||
}
|
||||
pub fn inactive_text() -> Color32 {
|
||||
theme::tokens().text_mute
|
||||
}
|
||||
|
||||
pub fn item_hover() -> Color32 {
|
||||
if use_dark() {
|
||||
ITEM_HOVER_DARK
|
||||
} else {
|
||||
ITEM_HOVER
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
// 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;
|
||||
pub mod icons;
|
||||
@@ -12,14 +12,14 @@
|
||||
// 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 lazy_static::lazy_static;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JByteArray, JObject, JString, JValue};
|
||||
@@ -30,185 +30,242 @@ use crate::gui::platform::PlatformCallbacks;
|
||||
/// Android platform implementation.
|
||||
#[derive(Clone)]
|
||||
pub struct Android {
|
||||
android_app: AndroidApp,
|
||||
/// Android related state.
|
||||
android_app: AndroidApp,
|
||||
|
||||
/// Context to repaint content and handle viewport commands.
|
||||
ctx: Arc<RwLock<Option<egui::Context>>>,
|
||||
}
|
||||
|
||||
impl Android {
|
||||
/// Create new Android platform instance from provided [`AndroidApp`].
|
||||
pub fn new(app: AndroidApp) -> Self {
|
||||
Self {
|
||||
android_app: app,
|
||||
}
|
||||
}
|
||||
/// 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<jni::sys::jvalue> {
|
||||
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
|
||||
}
|
||||
/// Call Android Activity method with JNI.
|
||||
pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option<jni::sys::jvalue> {
|
||||
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 show_keyboard(&self) {
|
||||
// Disable NDK soft input show call before fix for egui.
|
||||
// self.android_app.show_soft_input(false);
|
||||
fn set_context(&mut self, ctx: &egui::Context) {
|
||||
let mut w_ctx = self.ctx.write();
|
||||
*w_ctx = Some(ctx.clone());
|
||||
}
|
||||
|
||||
self.call_java_method("showKeyboard", "()V", &[]).unwrap();
|
||||
}
|
||||
fn exit(&self) {
|
||||
let _ = self.call_java_method("exit", "()V", &[]);
|
||||
}
|
||||
|
||||
fn hide_keyboard(&self) {
|
||||
// Disable NDK soft input hide call before fix for egui.
|
||||
// self.android_app.hide_soft_input(false);
|
||||
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))],
|
||||
);
|
||||
}
|
||||
|
||||
self.call_java_method("hideKeyboard", "()V", &[]).unwrap();
|
||||
}
|
||||
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 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();
|
||||
self.call_java_method("copyText",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(&JObject::from(arg_value))]).unwrap();
|
||||
}
|
||||
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 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 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 start_camera(&self) {
|
||||
// Clear image.
|
||||
let mut w_image = LAST_CAMERA_IMAGE.write();
|
||||
*w_image = None;
|
||||
// Start camera.
|
||||
self.call_java_method("startCamera", "()V", &[]).unwrap();
|
||||
}
|
||||
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
|
||||
let r_image = LAST_CAMERA_IMAGE.read();
|
||||
if r_image.is_some() {
|
||||
return Some(r_image.clone().unwrap());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn stop_camera(&self) {
|
||||
// Stop camera.
|
||||
self.call_java_method("stopCamera", "()V", &[]).unwrap();
|
||||
// Clear image.
|
||||
let mut w_image = LAST_CAMERA_IMAGE.write();
|
||||
*w_image = 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 camera_image(&self) -> Option<(Vec<u8>, u32)> {
|
||||
let r_image = LAST_CAMERA_IMAGE.read();
|
||||
if r_image.is_some() {
|
||||
return Some(r_image.clone().unwrap());
|
||||
}
|
||||
None
|
||||
}
|
||||
fn switch_camera(&self) {
|
||||
let _ = self.call_java_method("switchCamera", "()V", &[]);
|
||||
}
|
||||
|
||||
fn can_switch_camera(&self) -> bool {
|
||||
let result = self.call_java_method("camerasAmount", "()I", &[]).unwrap();
|
||||
let amount = unsafe { result.i };
|
||||
amount > 1
|
||||
}
|
||||
fn share_data(&self, name: String, data: Vec<u8>) -> 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 switch_camera(&self) {
|
||||
self.call_java_method("switchCamera", "()V", &[]).unwrap();
|
||||
}
|
||||
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 share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
|
||||
// Create file at cache dir.
|
||||
let default_cache = OsString::from(dirs::cache_dir().unwrap());
|
||||
let mut cache = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache));
|
||||
cache.push("images");
|
||||
std::fs::create_dir_all(cache.to_str().unwrap())?;
|
||||
cache.push(name);
|
||||
let mut image = File::create_new(cache.clone()).unwrap();
|
||||
image.write_all(data.as_slice()).unwrap();
|
||||
image.sync_all().unwrap();
|
||||
// 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(cache.to_str().unwrap()).unwrap();
|
||||
self.call_java_method("shareImage",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(&JObject::from(arg_value))]).unwrap();
|
||||
Ok(())
|
||||
}
|
||||
fn pick_file(&self) -> Option<String> {
|
||||
// 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_file(&self) -> Option<String> {
|
||||
// Clear previous result.
|
||||
let mut w_path = PICKED_FILE_PATH.write();
|
||||
*w_path = None;
|
||||
// Launch file picker.
|
||||
let _ = self.call_java_method("pickFile", "()V", &[]).unwrap();
|
||||
// Return empty string to identify async pick.
|
||||
Some("".to_string())
|
||||
}
|
||||
fn pick_folder(&self) -> Option<String> {
|
||||
// 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<String> {
|
||||
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 picked_file(&self) -> Option<String> {
|
||||
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", &[]);
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// Last image data from camera.
|
||||
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
/// Picked file path.
|
||||
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
/// Last image data from camera.
|
||||
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
/// Picked file path.
|
||||
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
/// Callback from Java code with last entered character from soft keyboard.
|
||||
#[allow(non_snake_case)]
|
||||
#[no_mangle]
|
||||
#[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,
|
||||
env: JNIEnv,
|
||||
_class: JObject,
|
||||
buff: jni::sys::jbyteArray,
|
||||
rotation: jni::sys::jint,
|
||||
) {
|
||||
let arr = unsafe { JByteArray::from_raw(buff) };
|
||||
let image : Vec<u8> = env.convert_byte_array(arr).unwrap();
|
||||
let mut w_image = LAST_CAMERA_IMAGE.write();
|
||||
*w_image = Some((image, rotation as u32));
|
||||
let arr = unsafe { JByteArray::from_raw(buff) };
|
||||
let image: Vec<u8> = 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)]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn Java_mw_gri_android_MainActivity_onFilePick(
|
||||
_env: JNIEnv,
|
||||
_class: JObject,
|
||||
char: jni::sys::jstring
|
||||
_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(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
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(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,197 +12,350 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io:: Write;
|
||||
use egui::{UserAttentionType, ViewportCommand, WindowLevel};
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
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 {
|
||||
/// Flag to check if camera stop is needed.
|
||||
stop_camera: Arc<AtomicBool>,
|
||||
}
|
||||
/// Context to repaint content and handle viewport commands.
|
||||
ctx: Arc<RwLock<Option<egui::Context>>>,
|
||||
|
||||
impl Default for Desktop {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stop_camera: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Cameras amount.
|
||||
cameras_amount: Arc<AtomicUsize>,
|
||||
/// Camera index.
|
||||
camera_index: Arc<AtomicUsize>,
|
||||
/// Flag to check if camera stop is needed.
|
||||
stop_camera: Arc<AtomicBool>,
|
||||
|
||||
impl PlatformCallbacks for Desktop {
|
||||
fn show_keyboard(&self) {}
|
||||
|
||||
fn hide_keyboard(&self) {}
|
||||
|
||||
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);
|
||||
|
||||
// Capture images at separate thread.
|
||||
thread::spawn(move || {
|
||||
Self::start_camera_capture(stop_camera);
|
||||
});
|
||||
}
|
||||
|
||||
fn stop_camera(&self) {
|
||||
// Stop camera.
|
||||
self.stop_camera.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
|
||||
let r_image = LAST_CAMERA_IMAGE.read();
|
||||
if r_image.is_some() {
|
||||
return r_image.clone();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn can_switch_camera(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn switch_camera(&self) {
|
||||
return;
|
||||
}
|
||||
|
||||
fn share_data(&self, name: String, data: Vec<u8>) -> 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<String> {
|
||||
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 picked_file(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Flag to check if attention required after window focusing.
|
||||
attention_required: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Desktop {
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn start_camera_capture(stop_camera: Arc<AtomicBool>) {
|
||||
use nokhwa::Camera;
|
||||
use nokhwa::pixel_format::RgbFormat;
|
||||
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
|
||||
let index = CameraIndex::Index(0);
|
||||
let requested = RequestedFormat::new::<RgbFormat>(
|
||||
RequestedFormatType::AbsoluteHighestFrameRate
|
||||
);
|
||||
// Create and open camera.
|
||||
let mut camera = Camera::new(index, requested).unwrap();
|
||||
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() {
|
||||
// Save image.
|
||||
let mut w_image = LAST_CAMERA_IMAGE.write();
|
||||
*w_image = Some((frame.buffer().to_vec(), 0));
|
||||
} else {
|
||||
// Clear image.
|
||||
let mut w_image = LAST_CAMERA_IMAGE.write();
|
||||
*w_image = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
camera.stop_stream().unwrap();
|
||||
};
|
||||
}
|
||||
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 = "windows"))]
|
||||
fn start_camera_capture(stop_camera: Arc<AtomicBool>) {
|
||||
use eye::hal::{traits::{Context, Device, Stream}, PlatformContext};
|
||||
use image::ImageEncoder;
|
||||
// #[allow(dead_code)]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn start_camera_capture(
|
||||
cameras_amount: Arc<AtomicUsize>,
|
||||
camera_index: Arc<AtomicUsize>,
|
||||
stop_camera: Arc<AtomicBool>,
|
||||
) {
|
||||
use nokhwa::Camera;
|
||||
use nokhwa::pixel_format::RgbFormat;
|
||||
use nokhwa::utils::ApiBackend;
|
||||
use nokhwa::utils::{FrameFormat, RequestedFormat, RequestedFormatType};
|
||||
|
||||
let ctx = PlatformContext::default();
|
||||
let devices = ctx.devices().unwrap();
|
||||
let dev = ctx.open_device(&devices[0].uri).unwrap();
|
||||
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;
|
||||
}
|
||||
|
||||
let streams = dev.streams().unwrap();
|
||||
let stream_desc = streams[0].clone();
|
||||
let w = stream_desc.width;
|
||||
let h = stream_desc.height;
|
||||
// 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::<RgbFormat>(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::<RgbFormat>() {
|
||||
let mut bytes: Vec<u8> = 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();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut stream = dev.start_stream(&stream_desc).unwrap();
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_camera_capture(
|
||||
cameras_amount: Arc<AtomicUsize>,
|
||||
camera_index: Arc<AtomicUsize>,
|
||||
stop_camera: Arc<AtomicBool>,
|
||||
) {
|
||||
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};
|
||||
|
||||
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.
|
||||
let frame = stream.next().expect("Stream is dead").expect("Failed to capture a frame");
|
||||
let mut out = vec![];
|
||||
if let Some(buf) = image::ImageBuffer::<image::Rgb<u8>, &[u8]>::from_raw(w, h, &frame) {
|
||||
image::codecs::jpeg::JpegEncoder::new(&mut out)
|
||||
.write_image(buf.as_raw(), w, h, image::ExtendedColorType::Rgb8).unwrap();
|
||||
} else {
|
||||
out = frame.to_vec();
|
||||
}
|
||||
// Save image.
|
||||
let mut w_image = LAST_CAMERA_IMAGE.write();
|
||||
*w_image = Some((out, 0));
|
||||
}
|
||||
}
|
||||
// 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::<RgbFormat>(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::<RgbFormat>().unwrap();
|
||||
let mut bytes: Vec<u8> = 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<u8>, 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<u8>) -> 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
/// Last captured image from started camera.
|
||||
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
@@ -22,16 +22,39 @@ pub mod platform;
|
||||
pub mod platform;
|
||||
|
||||
pub trait PlatformCallbacks {
|
||||
fn show_keyboard(&self);
|
||||
fn hide_keyboard(&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<u8>, u32)>;
|
||||
fn can_switch_camera(&self) -> bool;
|
||||
fn switch_camera(&self);
|
||||
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
|
||||
fn pick_file(&self) -> Option<String>;
|
||||
fn picked_file(&self) -> Option<String>;
|
||||
}
|
||||
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<u8>, u32)>;
|
||||
fn can_switch_camera(&self) -> bool;
|
||||
fn switch_camera(&self);
|
||||
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
|
||||
/// 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<String>;
|
||||
/// 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<String> {
|
||||
self.pick_file()
|
||||
}
|
||||
fn pick_folder(&self) -> Option<String>;
|
||||
fn picked_file(&self) -> Option<String>;
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// 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<ThemeKind> {
|
||||
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<Option<ThemeKind>> = 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 Cash App's brand-colored pay screen.
|
||||
#[must_use = "the override only lasts while the guard is alive"]
|
||||
pub struct ScopedTheme(Option<ThemeKind>);
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the status bar should use light (white) icons: true on the dark
|
||||
/// theme (dark top), false on the light/yellow themes (bright top).
|
||||
pub fn status_bar_white_icons() -> bool {
|
||||
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<DensityKind> {
|
||||
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()
|
||||
}
|
||||
@@ -12,432 +12,517 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
use std::thread;
|
||||
use eframe::emath::Align;
|
||||
use egui::load::SizedTexture;
|
||||
use egui::{Layout, Pos2, Rect, RichText, TextureOptions, Widget};
|
||||
use image::{DynamicImage, EncodableLayout, ImageFormat};
|
||||
|
||||
use egui::{Pos2, Rect, RichText, TextureOptions, UiBuilder, Widget};
|
||||
use grin_keychain::mnemonic::WORDS;
|
||||
use grin_util::ZeroingString;
|
||||
use grin_wallet_libwallet::SlatepackAddress;
|
||||
use grin_keychain::mnemonic::WORDS;
|
||||
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::types::{QrScanResult, QrScanState};
|
||||
use crate::gui::views::View;
|
||||
use crate::wallet::types::PhraseSize;
|
||||
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<RwLock<QrScanState>>,
|
||||
|
||||
/// Uniform Resources URIs collected from QR code scanning.
|
||||
ur_data: Arc<RwLock<Option<(Vec<String>, usize)>>>,
|
||||
/// QR code scanning progress and result.
|
||||
qr_scan_state: Arc<RwLock<QrScanState>>,
|
||||
/// Uniform Resources URIs collected from QR code scanning.
|
||||
ur_data: Arc<RwLock<Option<(Vec<String>, 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)),
|
||||
}
|
||||
}
|
||||
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) {
|
||||
// Draw last image from camera or loader.
|
||||
if let Some(img_data) = cb.camera_image() {
|
||||
// Load image to draw.
|
||||
if let Ok(mut img) =
|
||||
image::load_from_memory_with_format(&*img_data.0, ImageFormat::Jpeg) {
|
||||
// Process image to find QR code.
|
||||
self.scan_qr(&img);
|
||||
// Setup image rotation.
|
||||
img = match img_data.1 {
|
||||
90 => img.rotate90(),
|
||||
180 => img.rotate180(),
|
||||
270 => img.rotate270(),
|
||||
_ => img
|
||||
};
|
||||
// Convert to ColorImage to add at content.
|
||||
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);
|
||||
// Add image to content.
|
||||
ui.vertical_centered(|ui| {
|
||||
egui::Image::from_texture(sized_img)
|
||||
// Setup to crop image at square.
|
||||
.uv(Rect::from([
|
||||
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);
|
||||
});
|
||||
/// 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);
|
||||
|
||||
// Show UR scan progress.
|
||||
let show_ur_progress = {
|
||||
self.ur_data.clone().read().is_some()
|
||||
};
|
||||
let ur_progress = self.ur_progress();
|
||||
if show_ur_progress && ur_progress != 0 {
|
||||
ui.add_space(-52.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(RichText::new(format!("{}%", ur_progress))
|
||||
.size(16.0)
|
||||
.color(Colors::yellow()));
|
||||
});
|
||||
}
|
||||
// Draw image.
|
||||
let img_rect = self.image_ui(ui, img, img_data.1);
|
||||
|
||||
// Show button to switch cameras.
|
||||
if cb.can_switch_camera() {
|
||||
ui.add_space(-52.0);
|
||||
let mut size = ui.available_size();
|
||||
size.y = 48.0;
|
||||
ui.allocate_ui_with_layout(size, Layout::right_to_left(Align::Max), |ui| {
|
||||
ui.add_space(4.0);
|
||||
View::button(ui, CAMERA_ROTATE.to_string(), Colors::white_or_black(false), || {
|
||||
cb.switch_camera();
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.loading_content_ui(ui);
|
||||
}
|
||||
} else {
|
||||
self.loading_content_ui(ui);
|
||||
}
|
||||
img_rect
|
||||
} else {
|
||||
self.loading_ui(ui)
|
||||
}
|
||||
} else {
|
||||
self.loading_ui(ui)
|
||||
};
|
||||
|
||||
// Request redraw.
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Show UR scan progress.
|
||||
self.ur_progress_ui(ui, &rect);
|
||||
|
||||
/// Draw camera loading progress content.
|
||||
fn loading_content_ui(&self, ui: &mut egui::Ui) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
/// 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()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
|
||||
let image_data = image_data.clone();
|
||||
let qr_scan_state = self.qr_scan_state.clone();
|
||||
let ur_data = self.ur_data.clone();
|
||||
/// 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
|
||||
}
|
||||
|
||||
let on_scan = async move {
|
||||
// Prepare image data.
|
||||
let img = image_data.to_luma8();
|
||||
let mut img: rqrr::PreparedImage<image::GrayImage>
|
||||
= 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;
|
||||
}
|
||||
};
|
||||
/// 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
|
||||
}
|
||||
|
||||
// Launch scanner at separate thread.
|
||||
thread::spawn(move || {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(on_scan);
|
||||
});
|
||||
}
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Parse QR code scan result.
|
||||
fn parse_qr_code(data: Vec<u8>) -> 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));
|
||||
}
|
||||
}
|
||||
let image_data = image_data.clone();
|
||||
let qr_scan_state = self.qr_scan_state.clone();
|
||||
let ur_data = self.ur_data.clone();
|
||||
|
||||
// Check if string contains Slatepack message prefix and postfix.
|
||||
if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") {
|
||||
return QrScanResult::Slatepack(ZeroingString::from(text));
|
||||
}
|
||||
let on_scan = async move {
|
||||
// Prepare image data.
|
||||
let img = image_data.to_luma8();
|
||||
let mut img: rqrr::PreparedImage<image::GrayImage> = 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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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::<Vec<_>>();
|
||||
if let Some(index_total) = split.get(1) {
|
||||
if let Some((index, total)) = index_total.split_once("-") {
|
||||
let index = index.parse::<usize>();
|
||||
let total = total.parse::<usize>();
|
||||
if index.is_ok() && total.is_ok() {
|
||||
let index = index.unwrap() - 1;
|
||||
let total = total.unwrap();
|
||||
return QrScanResult::URPart(text_string, index, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Launch scanner at separate thread.
|
||||
thread::spawn(move || {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(on_scan);
|
||||
});
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
/// Parse QR code scan result.
|
||||
fn parse_qr_code(data: Vec<u8>) -> 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 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<char> = text.trim().chars().collect();
|
||||
let split = &chars.chunks(4)
|
||||
.map(|chunk| chunk.iter().collect::<String>()
|
||||
.trim()
|
||||
.trim_start_matches("0")
|
||||
.to_string()
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
let mut words = "".to_string();
|
||||
for i in split {
|
||||
let index = if i.is_empty() {
|
||||
0usize
|
||||
} else {
|
||||
i.parse::<usize>().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));
|
||||
}
|
||||
}
|
||||
// Check if string contains Slatepack message prefix and postfix.
|
||||
if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") {
|
||||
return QrScanResult::Slatepack(text.to_string());
|
||||
}
|
||||
|
||||
// Return default text result.
|
||||
QrScanResult::Text(ZeroingString::from(text))
|
||||
}
|
||||
// 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::<Vec<_>>();
|
||||
if let Some(index_total) = split.get(1) {
|
||||
if let Some((index, total)) = index_total.split_once("-") {
|
||||
let index = index.parse::<usize>();
|
||||
let total = total.parse::<usize>();
|
||||
if index.is_ok() && total.is_ok() {
|
||||
let index = index.unwrap() - 1;
|
||||
let total = total.unwrap();
|
||||
return QrScanResult::URPart(text_string, index, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get QR code scan result.
|
||||
pub fn qr_scan_result(&self) -> Option<QrScanResult> {
|
||||
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
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset camera content state to default.
|
||||
pub fn clear_state(&mut self) {
|
||||
// Clear QR code scanning state.
|
||||
let mut w_scan = self.qr_scan_state.write();
|
||||
*w_scan = QrScanState::default();
|
||||
// Clear UR data.
|
||||
let mut w_data = self.ur_data.write();
|
||||
*w_data = None;
|
||||
}
|
||||
}
|
||||
// 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<char> = text.trim().chars().collect();
|
||||
let split = &chars
|
||||
.chunks(4)
|
||||
.map(|chunk| {
|
||||
chunk
|
||||
.iter()
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.trim_start_matches("0")
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut words = "".to_string();
|
||||
for i in split {
|
||||
let index = if i.is_empty() {
|
||||
0usize
|
||||
} else {
|
||||
i.parse::<usize>().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<QrScanResult> {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,432 +12,359 @@
|
||||
// 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 egui::os::OperatingSystem;
|
||||
use egui::{Align, Layout, RichText};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
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::gui::views::types::{ModalContainer, ModalPosition};
|
||||
use crate::node::Node;
|
||||
use crate::{AppConfig, Settings};
|
||||
use crate::gui::icons::{CHECK, CHECK_FAT, FILE_X};
|
||||
use crate::gui::views::network::{NetworkContent, NodeSetup};
|
||||
use crate::gui::views::wallets::WalletsContent;
|
||||
|
||||
lazy_static! {
|
||||
/// Global state to check if [`NetworkContent`] panel is open.
|
||||
static ref NETWORK_PANEL_OPEN: AtomicBool = AtomicBool::new(false);
|
||||
/// 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.
|
||||
pub wallets: WalletsContent,
|
||||
/// Side panel [`NetworkContent`] content.
|
||||
network: NetworkContent,
|
||||
|
||||
/// Check if app exit is allowed on close event of [`eframe::App`] implementation.
|
||||
pub(crate) exit_allowed: bool,
|
||||
/// Flag to show exit progress at [`Modal`].
|
||||
show_exit_progress: bool,
|
||||
/// Central panel [`WalletsContent`] content.
|
||||
wallets: WalletsContent,
|
||||
|
||||
/// Flag to check it's first draw of content.
|
||||
first_draw: bool,
|
||||
/// 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,
|
||||
|
||||
/// List of allowed [`Modal`] ids for this [`ModalContainer`].
|
||||
allowed_modal_ids: Vec<&'static str>
|
||||
/// 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,
|
||||
allowed_modal_ids: vec![
|
||||
Self::EXIT_CONFIRMATION_MODAL,
|
||||
Self::SETTINGS_MODAL,
|
||||
Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL,
|
||||
Self::CRASH_REPORT_MODAL
|
||||
],
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalContainer for Content {
|
||||
fn modal_ids(&self) -> &Vec<&'static str> {
|
||||
&self.allowed_modal_ids
|
||||
}
|
||||
/// 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";
|
||||
|
||||
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),
|
||||
Self::SETTINGS_MODAL => self.settings_modal_ui(ui, modal),
|
||||
Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL => self.android_warning_modal_ui(ui, modal),
|
||||
Self::CRASH_REPORT_MODAL => self.crash_report_modal_ui(ui, modal, cb),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
/// Identifier for exit confirmation [`Modal`].
|
||||
pub const EXIT_CONFIRMATION_MODAL: &'static str = "exit_confirmation_modal";
|
||||
/// Identifier for wallet opening [`Modal`].
|
||||
pub const SETTINGS_MODAL: &'static str = "settings_modal";
|
||||
/// 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";
|
||||
/// 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;
|
||||
|
||||
/// 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";
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
// Draw modal content for current ui container.
|
||||
self.current_modal_ui(ui, cb);
|
||||
/// 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
|
||||
}
|
||||
|
||||
let dual_panel = Self::is_dual_panel_mode(ui);
|
||||
let (is_panel_open, panel_width) = Self::network_panel_state_width(ui, dual_panel);
|
||||
/// 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
|
||||
}
|
||||
|
||||
// Show network content.
|
||||
egui::SidePanel::left("network_panel")
|
||||
.resizable(false)
|
||||
.exact_width(panel_width)
|
||||
.frame(egui::Frame {
|
||||
..Default::default()
|
||||
})
|
||||
.show_animated_inside(ui, is_panel_open, |ui| {
|
||||
self.network.ui(ui, cb);
|
||||
});
|
||||
/// 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);
|
||||
}
|
||||
|
||||
// Show wallets content.
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
..Default::default()
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
self.wallets.ui(ui, cb);
|
||||
});
|
||||
/// Check if [`NetworkContent`] panel is open.
|
||||
pub fn is_network_panel_open() -> bool {
|
||||
NETWORK_PANEL_OPEN.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
if self.first_draw {
|
||||
// Show crash report if needed.
|
||||
if AppConfig::show_crash() {
|
||||
Modal::new(Self::CRASH_REPORT_MODAL)
|
||||
.closeable(false)
|
||||
.position(ModalPosition::Center)
|
||||
.title(t!("crash_report"))
|
||||
.show();
|
||||
} else {
|
||||
// Show integrated node warning on Android if needed.
|
||||
if OperatingSystem::from_target_os() == OperatingSystem::Android &&
|
||||
AppConfig::android_integrated_node_warning_needed() {
|
||||
Modal::new(Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL)
|
||||
.title(t!("network.node"))
|
||||
.show();
|
||||
}
|
||||
}
|
||||
self.first_draw = false;
|
||||
}
|
||||
}
|
||||
/// Show exit confirmation [`Modal`].
|
||||
pub fn show_exit_modal() {
|
||||
Modal::new(Self::EXIT_CONFIRMATION_MODAL)
|
||||
.title(t!("confirmation"))
|
||||
.show();
|
||||
}
|
||||
|
||||
/// Get [`NetworkContent`] panel state and width.
|
||||
fn network_panel_state_width(ui: &mut egui::Ui, dual_panel: bool) -> (bool, f32) {
|
||||
let is_panel_open = dual_panel || Self::is_network_panel_open();
|
||||
let panel_width = if dual_panel {
|
||||
Self::SIDE_PANEL_WIDTH + View::get_left_inset()
|
||||
} else {
|
||||
let is_fullscreen = ui.ctx().input(|i| {
|
||||
i.viewport().fullscreen.unwrap_or(false)
|
||||
});
|
||||
View::window_size(ui).0 - if View::is_desktop() && !is_fullscreen &&
|
||||
OperatingSystem::from_target_os() != OperatingSystem::Mac {
|
||||
Self::WINDOW_FRAME_MARGIN * 2.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
(is_panel_open, panel_width)
|
||||
}
|
||||
/// 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);
|
||||
|
||||
/// Check if ui can show [`NetworkContent`] and [`WalletsContent`] at same time.
|
||||
pub fn is_dual_panel_mode(ui: &egui::Ui) -> bool {
|
||||
let (w, h) = View::window_size(ui);
|
||||
// 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
|
||||
}
|
||||
// Setup spacing between buttons.
|
||||
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
|
||||
|
||||
/// 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if [`NetworkContent`] panel is open.
|
||||
pub fn is_network_panel_open() -> bool {
|
||||
NETWORK_PANEL_OPEN.load(Ordering::Relaxed)
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Show exit confirmation [`Modal`].
|
||||
pub fn show_exit_modal() {
|
||||
Modal::new(Self::EXIT_CONFIRMATION_MODAL)
|
||||
.title(t!("modal.confirmation"))
|
||||
.show();
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw exit confirmation modal content.
|
||||
fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal) {
|
||||
if self.show_exit_progress {
|
||||
if !Node::is_running() {
|
||||
self.exit_allowed = true;
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
modal.close();
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
View::small_loading_spinner(ui);
|
||||
ui.add_space(12.0);
|
||||
ui.label(RichText::new(t!("sync_status.shutdown"))
|
||||
.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), |ui| {
|
||||
if !Node::is_running() {
|
||||
self.exit_allowed = true;
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Back key event.
|
||||
pub fn on_back(&mut self) {
|
||||
if Modal::on_back() {
|
||||
if self.wallets.on_back() {
|
||||
Self::show_exit_modal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw creating wallet name/password input [`Modal`] content.
|
||||
pub fn settings_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal) {
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Draw chain type selection.
|
||||
NodeSetup::chain_type_ui(ui);
|
||||
|
||||
ui.add_space(8.0);
|
||||
View::horizontal_line(ui, Colors::item_stroke());
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Draw theme selection.
|
||||
Self::theme_selection_ui(ui);
|
||||
|
||||
ui.add_space(8.0);
|
||||
View::horizontal_line(ui, Colors::item_stroke());
|
||||
ui.add_space(6.0);
|
||||
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(RichText::new(format!("{}:", t!("language")))
|
||||
.size(16.0)
|
||||
.color(Colors::gray())
|
||||
);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Draw available list of languages to select.
|
||||
let locales = rust_i18n::available_locales!();
|
||||
for (index, locale) in locales.iter().enumerate() {
|
||||
Self::language_item_ui(locale, ui, index, locales.len(), modal);
|
||||
}
|
||||
|
||||
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 theme selection content.
|
||||
fn theme_selection_ui(ui: &mut egui::Ui) {
|
||||
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(8.0);
|
||||
|
||||
if saved_use_dark != selected_use_dark {
|
||||
AppConfig::set_dark_theme(selected_use_dark);
|
||||
crate::setup_visuals(ui.ctx());
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw language selection item content.
|
||||
fn language_item_ui(locale: &str, ui: &mut egui::Ui, index: usize, len: usize, modal: &Modal) {
|
||||
// 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 item_rounding = View::item_rounding(index, len, false);
|
||||
ui.painter().rect(bg_rect, item_rounding, Colors::fill(), View::item_stroke());
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| {
|
||||
// Draw button to select language.
|
||||
let is_current = if let Some(lang) = AppConfig::locale() {
|
||||
lang == locale
|
||||
} else {
|
||||
rust_i18n::locale() == locale
|
||||
};
|
||||
if !is_current {
|
||||
View::item_button(ui, View::item_rounding(index, len, true), CHECK, None, || {
|
||||
rust_i18n::set_locale(locale);
|
||||
AppConfig::save_locale(locale);
|
||||
modal.close();
|
||||
});
|
||||
} else {
|
||||
ui.add_space(14.0);
|
||||
ui.label(RichText::new(CHECK_FAT).size(20.0).color(Colors::green()));
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
|
||||
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(3.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw content for integrated node warning [`Modal`] on Android.
|
||||
fn android_warning_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal) {
|
||||
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,
|
||||
modal: &Modal,
|
||||
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 crash report.
|
||||
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::crash_report_path()) {
|
||||
cb.share_data(Settings::CRASH_REPORT_FILE_NAME.to_string(),
|
||||
data.as_bytes().to_vec()).unwrap_or_default()
|
||||
}
|
||||
AppConfig::set_show_crash(false);
|
||||
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), || {
|
||||
AppConfig::set_show_crash(false);
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// 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 std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::{fs, thread};
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::ARCHIVE_BOX;
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::views::View;
|
||||
|
||||
/// Button to pick file and parse its data into text.
|
||||
pub struct FilePickButton {
|
||||
/// Flag to check if file is picking.
|
||||
pub file_picking: Arc<AtomicBool>,
|
||||
/// Flag to check if file is parsing.
|
||||
pub file_parsing: Arc<AtomicBool>,
|
||||
/// File parsing result.
|
||||
pub file_parsing_result: Arc<RwLock<Option<String>>>
|
||||
}
|
||||
|
||||
impl Default for FilePickButton {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
file_picking: Arc::new(AtomicBool::new(false)),
|
||||
file_parsing: Arc::new(AtomicBool::new(false)),
|
||||
file_parsing_result: Arc::new(RwLock::new(None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FilePickButton {
|
||||
/// Draw button content.
|
||||
pub fn ui(&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
cb: &dyn PlatformCallbacks,
|
||||
on_result: 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() {
|
||||
self.on_file_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.
|
||||
on_result(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.
|
||||
let file_text = format!("{} {}", ARCHIVE_BOX, t!("choose_file"));
|
||||
View::colored_text_button(ui, file_text, Colors::blue(), Colors::button(), || {
|
||||
if let Some(path) = cb.pick_file() {
|
||||
self.on_file_pick(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle picked file path.
|
||||
fn on_file_pick(&self, path: String) {
|
||||
// Wait for asynchronous file pick result if path is empty.
|
||||
if path.is_empty() {
|
||||
self.file_picking.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<AtomicBool>,
|
||||
|
||||
/// 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<AtomicBool>,
|
||||
/// File parsing result.
|
||||
file_parsing_result: Arc<RwLock<Option<String>>>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// 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<u8>),
|
||||
/// The server confirmed the name has no avatar.
|
||||
Absent,
|
||||
/// The probe failed (network/Tor) — 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<String, Option<egui::TextureHandle>>,
|
||||
inflight: HashSet<String>,
|
||||
tx: Sender<FetchResult>,
|
||||
rx: Receiver<FetchResult>,
|
||||
}
|
||||
|
||||
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<egui::ColorImage> {
|
||||
// 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<egui::TextureHandle> {
|
||||
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/Tor failure: leave the entry stale so the next
|
||||
// frame retries once a circuit is healthy. 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
//! 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<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
/// Network fee in atomic units (sends only; unknown for receives).
|
||||
pub fee: Option<u64>,
|
||||
pub slate_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the receipt detail for a transaction id.
|
||||
pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
||||
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<TxNostrMeta> = 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))
|
||||
};
|
||||
let confs = if tx.data.confirmed {
|
||||
None
|
||||
} else {
|
||||
match tx.height {
|
||||
Some(h) if h > 0 && data.info.last_confirmed_height >= h => Some((
|
||||
data.info.last_confirmed_height - h + 1,
|
||||
data.info.minimum_confirmations,
|
||||
)),
|
||||
_ => Some((0, data.info.minimum_confirmations)),
|
||||
}
|
||||
};
|
||||
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<ActivityItem> {
|
||||
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 → @user (verified goblin.st) → user@domain → npub short.
|
||||
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::relays::HOME_NIP05_DOMAIN {
|
||||
return format!("@{}", name);
|
||||
}
|
||||
return nip05.clone();
|
||||
}
|
||||
}
|
||||
short_npub(&contact.npub)
|
||||
}
|
||||
|
||||
/// 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<char> = 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<ActivityItem> {
|
||||
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<ActivityItem> = 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<TxNostrMeta> = 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
|
||||
}
|
||||
@@ -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##"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}" role="img"><defs><linearGradient id="g" gradientUnits="objectBoundingBox" gradientTransform="rotate({angle:.1},0.5,0.5)"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs><rect width="{size}" height="{size}" fill="url(#g)"/></svg>"##
|
||||
)
|
||||
}
|
||||
|
||||
/// 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##"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}" role="img"><defs><linearGradient id="g{id_suffix}" gradientUnits="objectBoundingBox" gradientTransform="rotate({angle:.1},0.5,0.5)"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs><rect width="{size}" height="{size}" fill="url(#g{id_suffix})"/><g transform="translate({off:.2},{off:.2}) scale({scale:.4})"><path d="{GRIN_PATH}" fill="#000000" fill-opacity="{LOGO_OPACITY}"/></g></svg>"##
|
||||
)
|
||||
}
|
||||