Compare commits
69 Commits
v0.3.6-dev
...
build69
| 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 |
@@ -1,71 +0,0 @@
|
||||
name: Test build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- "*"
|
||||
branches-ignore:
|
||||
- master
|
||||
- ci
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout submodules
|
||||
run: |
|
||||
sed -i 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
|
||||
git submodule update --init
|
||||
- name: Check commit
|
||||
id: check
|
||||
run: |
|
||||
git fetch && git checkout master
|
||||
sha=$(git rev-parse HEAD)
|
||||
[[ "${{ github.sha }}" == "${sha}" ]] && test=false || test=true
|
||||
echo "test=${test}" >> "$FORGEJO_OUTPUT"
|
||||
- name: Restore cargo cache
|
||||
id: cache-cargo-restore
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-test-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Setup registry
|
||||
run: |
|
||||
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
|
||||
- name: Build
|
||||
if: ${{ steps.check.outputs.test == 'true' }}
|
||||
run: cargo build --release
|
||||
- name: Save cargo cache
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
|
||||
- name: Telegram Notify Channel
|
||||
if: ${{ steps.check.outputs.test == 'true' && (success() || failure()) }}
|
||||
uses: actions/telegram-notifier@main
|
||||
with:
|
||||
api_url: ${{ secrets.TELEGRAM_API_URL }}
|
||||
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
chat_id: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
status: ${{ job.status }}
|
||||
notify_fields: "actor,repository,workflow,branch,commit"
|
||||
- name: Telegram Notify Group
|
||||
if: ${{ steps.check.outputs.test == 'true' && (success() || failure()) }}
|
||||
uses: actions/telegram-notifier@main
|
||||
with:
|
||||
api_url: ${{ secrets.TELEGRAM_API_URL }}
|
||||
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
chat_id: ${{ secrets.TELEGRAM_GROUP_ID }}
|
||||
status: ${{ job.status }}
|
||||
notify_fields: "actor,repository,workflow,branch,commit"
|
||||
@@ -1,29 +0,0 @@
|
||||
name: Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: debian-release
|
||||
|
||||
steps:
|
||||
- name: Telegram Notify Channel
|
||||
uses: actions/telegram-notifier@main
|
||||
with:
|
||||
api_url: ${{ secrets.TELEGRAM_API_URL }}
|
||||
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
chat_id: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
status: ${{ job.status }}
|
||||
notify_fields: "actor,repository"
|
||||
- name: Telegram Notify Group
|
||||
uses: actions/telegram-notifier@main
|
||||
with:
|
||||
api_url: ${{ secrets.TELEGRAM_API_URL }}
|
||||
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
chat_id: ${{ secrets.TELEGRAM_GROUP_ID }}
|
||||
status: ${{ job.status }}
|
||||
notify_fields: "actor,repository"
|
||||
@@ -1,474 +0,0 @@
|
||||
name: Release build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- ci
|
||||
tags-ignore:
|
||||
- "*-dev*"
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: debian-release
|
||||
outputs:
|
||||
v: ${{ steps.version.outputs.v }}
|
||||
pre: ${{ steps.version.outputs.pre }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
last_tag: ${{ steps.check_prev.outputs.last_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout submodules
|
||||
run: |
|
||||
sed -i 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
|
||||
git submodule update --init
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
ver="$(grep -m 1 -Po 'version = "\K[^"]*' Cargo.toml)"
|
||||
[[ $ver == *"-dev"* ]] && ver=${ver} || ver=${ver}-dev
|
||||
[[ ${{ forgejo.ref_type }} == 'tag' ]] && app_ver=${{ forgejo.ref_name }} || app_ver=v${ver}
|
||||
echo "v=${app_ver}" >> "$FORGEJO_OUTPUT"
|
||||
echo $app_ver
|
||||
[[ ${{ forgejo.ref_type }} == 'tag' ]] && pre='false' || pre='true'
|
||||
echo "pre=${pre}" >> "$FORGEJO_OUTPUT"
|
||||
echo "pre-release: ${pre}"
|
||||
- name: Check existing release
|
||||
if: ${{ forgejo.ref_type == 'tag' }}
|
||||
id: check
|
||||
run: |
|
||||
git fetch --tags
|
||||
dev_sha=$(git rev-parse refs/tags/${{ forgejo.ref_name }}-dev) || :
|
||||
[[ "$(git show-ref)" == *"${dev_sha}"* ]] && exists='true' || exists='false'
|
||||
echo "exists=${exists}" >> "$FORGEJO_OUTPUT"
|
||||
echo ${exists}
|
||||
mkdir release
|
||||
- uses: actions/forgejo-release@v2.11.3
|
||||
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
|
||||
with:
|
||||
direction: download
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
tag: "${{ forgejo.ref_name }}-dev"
|
||||
release-dir: ./release
|
||||
- name: Rename files
|
||||
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
|
||||
working-directory: release
|
||||
run: for f in *; do mv "$f" "$(echo "$f" | sed s/-dev-/-/)"; done
|
||||
- name: Delete dev release
|
||||
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
|
||||
uses: actions/delete-release@v1
|
||||
with:
|
||||
release_name: "${{ forgejo.ref_name }}-dev"
|
||||
- name: Check previous release
|
||||
id: check_prev
|
||||
run: |
|
||||
git fetch --tags
|
||||
[[ ${{ forgejo.ref_type }} == 'tag' ]] && skip=0 || skip=1
|
||||
last_tag=$(git describe --abbrev=0 --tags $(git rev-list --tags --skip=${skip} --max-count=1)) || true
|
||||
echo "last_tag=${last_tag}" >> "$FORGEJO_OUTPUT"
|
||||
- uses: actions/forgejo-release@v2.11.3
|
||||
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
|
||||
with:
|
||||
direction: upload
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
tag: ${{ forgejo.ref_name }}
|
||||
override: false
|
||||
prerelease: false
|
||||
release-dir: ./release
|
||||
release-notes: "Full Changelog: [${{ steps.check_prev.outputs.last_tag }}...${{ steps.version.outputs.v }}](https://code.gri.mw/${{ forgejo.repository }}/compare/${{ steps.check_prev.outputs.last_tag }}...${{ steps.version.outputs.v }})"
|
||||
|
||||
android:
|
||||
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
|
||||
runs-on: macos
|
||||
needs: version
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout submodules
|
||||
run: |
|
||||
sed -i -- 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
|
||||
git submodule update --init
|
||||
- name: Restore cargo cache
|
||||
id: cache-cargo-restore
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: grim-android-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Build libs
|
||||
run: |
|
||||
rustup -q update
|
||||
chmod +x scripts/android.sh && ./scripts/android.sh lib ${{ needs.version.outputs.v }}
|
||||
- name: Save cargo cache
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
|
||||
- name: Restore gradle cache
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: grim-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
- name: Setup build
|
||||
run: |
|
||||
chmod +x android/gradlew
|
||||
echo "${{ secrets.ANDROID_KEYSTORE }}" > release.keystore.txt
|
||||
base64 -d < release.keystore.txt -o android/keystore
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_PROPS }}" > release.keystore.props.txt
|
||||
base64 -d < release.keystore.props.txt -o android/keystore.properties
|
||||
mkdir ~/.gradle && touch ~/.gradle/gradle.properties
|
||||
printf "mavenHost=${{ secrets.MAVEN_LOCAL_HOST }}\n" >> ~/.gradle/gradle.properties
|
||||
printf "mavenUser=${{ secrets.MAVEN_USER }}\n" >> ~/.gradle/gradle.properties
|
||||
printf "mavenPassword=${{ secrets.MAVEN_PASSWORD }}" >> ~/.gradle/gradle.properties
|
||||
- name: Release ARMv7+v8 APK
|
||||
working-directory: android
|
||||
run: |
|
||||
jni_path=app/src/main/jniLibs
|
||||
mv ${jni_path}/x86_64 x86_64
|
||||
./gradlew assembleCiSignedRelease
|
||||
apk_path=app/build/outputs/apk/ci/signedRelease/app-ci-signedRelease.apk
|
||||
name=grim-${{ needs.version.outputs.v }}-android.apk
|
||||
mv ${apk_path} "${name}"
|
||||
- name: Checksum ARM APK
|
||||
working-directory: android
|
||||
run: |
|
||||
name=grim-${{ needs.version.outputs.v }}-android.apk
|
||||
checksum=grim-${{ needs.version.outputs.v }}-android-sha256sum.txt
|
||||
sha256sum "${name}" > "${checksum}"
|
||||
- name: Release x86_64 APK
|
||||
working-directory: android
|
||||
run: |
|
||||
./gradlew clean
|
||||
jni_path=app/src/main/jniLibs
|
||||
rm -rf ${jni_path}/*
|
||||
mv x86_64 ${jni_path}
|
||||
./gradlew assembleCiSignedRelease
|
||||
apk_path=app/build/outputs/apk/ci/signedRelease/app-ci-signedRelease.apk
|
||||
name=grim-${{ needs.version.outputs.v }}-android-x86_64.apk
|
||||
mv ${apk_path} "${name}"
|
||||
- name: Save gradle cache
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
|
||||
- name: Checksum x86_64 APK
|
||||
working-directory: android
|
||||
run: |
|
||||
name=grim-${{ needs.version.outputs.v }}-android.apk
|
||||
checksum=grim-${{ needs.version.outputs.v }}-android-x86_64-sha256sum.txt
|
||||
sha256sum "${name}" > "${checksum}"
|
||||
- name: Upload artifacts
|
||||
run: |
|
||||
mkdir release
|
||||
mv android/grim* release
|
||||
tar -czf android.tar.gz release
|
||||
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file android.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/android.tar.gz
|
||||
|
||||
linux_arm:
|
||||
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
|
||||
runs-on: debian-rust-arm
|
||||
needs: [version]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout submodules
|
||||
run: |
|
||||
sed -i 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
|
||||
git submodule update --init
|
||||
- name: Restore cargo cache
|
||||
id: cache-cargo-restore
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: grim-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Setup registry
|
||||
run: |
|
||||
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
|
||||
- name: Release Linux ARM
|
||||
run: |
|
||||
rustup -q update
|
||||
cargo zigbuild --release --target aarch64-unknown-linux-gnu
|
||||
- name: Save cargo cache
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
|
||||
- name: Upload artifacts
|
||||
run: |
|
||||
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file target/aarch64-unknown-linux-gnu/release/grim ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/grim-linux-arm
|
||||
|
||||
linux_arm_appimage:
|
||||
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
|
||||
runs-on: debian-arm
|
||||
needs: [version, linux_arm]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Download Artifact
|
||||
run: |
|
||||
wget ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/grim-linux-arm
|
||||
- name: AppImage
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir release
|
||||
chmod +x grim-linux-arm
|
||||
mv grim-linux-arm linux/Grim.AppDir/AppRun
|
||||
ARCH=aarch64 appimagetool linux/Grim.AppDir grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
|
||||
mv grim-${{ needs.version.outputs.v }}-linux-arm.AppImage release/
|
||||
- name: Checksum AppImage ARM
|
||||
working-directory: release
|
||||
run: sha256sum grim-${{ needs.version.outputs.v }}-linux-arm.AppImage > grim-${{ needs.version.outputs.v }}-linux-arm-appimage-sha256sum.txt
|
||||
- name: Upload artifacts
|
||||
run: |
|
||||
tar -czf linux-arm.appimage.tar.gz release
|
||||
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-arm.appimage.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-arm.appimage.tar.gz
|
||||
|
||||
linux_x86:
|
||||
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
|
||||
runs-on: debian-rust-x86_64
|
||||
needs: [version]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout submodules
|
||||
run: |
|
||||
sed -i 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
|
||||
git submodule update --init
|
||||
- name: Restore cargo cache
|
||||
id: cache-cargo-restore
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: grim-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Setup registry
|
||||
run: |
|
||||
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
|
||||
- name: Release Linux x86
|
||||
run: |
|
||||
rustup -q update
|
||||
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
||||
- name: Save cargo cache
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
|
||||
- name: AppImage x86
|
||||
run: |
|
||||
mkdir release
|
||||
mv target/x86_64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
|
||||
appimagetool linux/Grim.AppDir grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
|
||||
mv grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage release/
|
||||
- name: Checksum AppImage x86
|
||||
working-directory: release
|
||||
run: sha256sum grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage > grim-${{ needs.version.outputs.v }}-linux-x86_64-appimage-sha256sum.txt
|
||||
- name: Upload artifacts
|
||||
run: |
|
||||
tar -czf linux-x86_64.appimage.tar.gz release
|
||||
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-x86_64.appimage.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-x86_64.appimage.tar.gz
|
||||
|
||||
macos:
|
||||
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
|
||||
runs-on: macos
|
||||
needs: [version, android]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout submodules
|
||||
run: |
|
||||
sed -i -- 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
|
||||
git submodule update --init
|
||||
- run: mkdir release
|
||||
- name: Restore cargo cache
|
||||
id: cache-cargo-restore
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: grim-macos-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Release MacOS Universal
|
||||
run: |
|
||||
rustup -q update
|
||||
export MACOSX_DEPLOYMENT_TARGET=11.0
|
||||
cargo build --release --target x86_64-apple-darwin
|
||||
cargo build --release --target aarch64-apple-darwin
|
||||
lipo -create -output grim "target/x86_64-apple-darwin/release/grim" "target/aarch64-apple-darwin/release/grim"
|
||||
mv grim macos/Grim.app/Contents/MacOS
|
||||
- name: Save cargo cache
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
|
||||
- name: Archive Universal
|
||||
working-directory: macos
|
||||
run: |
|
||||
zip -r grim-${{ needs.version.outputs.v }}-macos-universal.zip Grim.app
|
||||
mv grim-${{ needs.version.outputs.v }}-macos-universal.zip ../release
|
||||
- name: Checksum Release Universal
|
||||
working-directory: release
|
||||
run: sha256sum grim-${{ needs.version.outputs.v }}-macos-universal.zip > grim-${{ needs.version.outputs.v }}-macos-universal-sha256sum.txt
|
||||
- name: Upload artifacts
|
||||
run: |
|
||||
tar -czf macos.tar.gz release
|
||||
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file macos.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/macos.tar.gz
|
||||
|
||||
windows:
|
||||
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
|
||||
runs-on: windows
|
||||
needs: [version]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout submodules
|
||||
run: |
|
||||
(Get-content .gitmodules) | Foreach-Object {$_ -replace "https://code.gri.mw", "${{ secrets.REPO_HOST }}"} | Set-Content .gitmodules
|
||||
git submodule update --init
|
||||
- name: Update UpgradeCode
|
||||
shell: powershell
|
||||
run: |
|
||||
$guid = [guid]::NewGuid().ToString()
|
||||
$wix = [xml](Get-Content wix/main.wxs)
|
||||
$wix.Wix.Product.UpgradeCode = $guid
|
||||
$wix.Save("wix/main.wxs")
|
||||
Get-Content wix/main.wxs
|
||||
- run: mkdir release
|
||||
- name: Release Windows x86
|
||||
run: |
|
||||
rustup -q update
|
||||
cargo wix -p grim -o grim-${{ needs.version.outputs.v }}-win-x86_64.msi --nocapture
|
||||
mv grim-${{ needs.version.outputs.v }}-win-x86_64.msi release\
|
||||
Compress-Archive -Path target\release\grim.exe -DestinationPath grim-${{ needs.version.outputs.v }}-win-x86_64.zip
|
||||
mv grim-${{ needs.version.outputs.v }}-win-x86_64.zip release\
|
||||
- name: Checksum Archive x86
|
||||
working-directory: release
|
||||
run: |
|
||||
certutil -hashfile grim-${{ needs.version.outputs.v }}-win-x86_64.msi SHA256 > grim-${{ needs.version.outputs.v }}-win-x86_64-msi-sha256sum.txt
|
||||
certutil -hashfile grim-${{ needs.version.outputs.v }}-win-x86_64.zip SHA256 > grim-${{ needs.version.outputs.v }}-win-x86_64-sha256sum.txt
|
||||
- name: Upload artifacts
|
||||
run: |
|
||||
tar -czf windows.tar.gz release
|
||||
Remove-Item alias:curl
|
||||
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file windows.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/windows.tar.gz
|
||||
|
||||
release:
|
||||
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
|
||||
runs-on: debian-release
|
||||
needs: [version, android, linux_x86, linux_arm_appimage, macos, windows]
|
||||
steps:
|
||||
- name: Download All Artifacts
|
||||
run: |
|
||||
curl -s -o android.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/android.tar.gz
|
||||
tar -xzf android.tar.gz
|
||||
rm android.tar.gz
|
||||
curl -s -o linux-x86_64.appimage.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-x86_64.appimage.tar.gz
|
||||
tar -xzf linux-x86_64.appimage.tar.gz
|
||||
rm linux-x86_64.appimage.tar.gz
|
||||
curl -s -o linux-arm.appimage.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-arm.appimage.tar.gz
|
||||
tar -xzf linux-arm.appimage.tar.gz
|
||||
rm linux-arm.appimage.tar.gz
|
||||
curl -s -o macos.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/macos.tar.gz
|
||||
tar -xzf macos.tar.gz
|
||||
rm macos.tar.gz
|
||||
curl -s -o windows.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/windows.tar.gz
|
||||
tar -xzf windows.tar.gz
|
||||
rm windows.tar.gz
|
||||
- name: Upload release to Forgejo
|
||||
uses: actions/forgejo-release@v2.11.3
|
||||
with:
|
||||
direction: upload
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
tag: ${{ needs.version.outputs.v }}
|
||||
override: true
|
||||
prerelease: ${{ needs.version.outputs.pre == 'true' }}
|
||||
release-dir: release
|
||||
release-notes: "Full Changelog: [${{ needs.version.outputs.last_tag }}...${{ needs.version.outputs.v }}](https://code.gri.mw/${{ forgejo.repository }}/compare/${{ needs.version.outputs.last_tag }}...${{ needs.version.outputs.v }})"
|
||||
- name: Telegram Notify Channel
|
||||
if: always()
|
||||
uses: actions/telegram-notifier@main
|
||||
with:
|
||||
api_url: ${{ secrets.TELEGRAM_API_URL }}
|
||||
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
chat_id: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
status: ${{ job.status }}
|
||||
notify_fields: "actor,repository,workflow,branch,commit"
|
||||
- name: Telegram Notify Group
|
||||
if: always()
|
||||
uses: actions/telegram-notifier@main
|
||||
with:
|
||||
api_url: ${{ secrets.TELEGRAM_API_URL }}
|
||||
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
chat_id: ${{ secrets.TELEGRAM_GROUP_ID }}
|
||||
status: ${{ job.status }}
|
||||
notify_fields: "actor,repository,workflow,branch,commit"
|
||||
|
||||
release-telegram:
|
||||
runs-on: debian-release
|
||||
needs: [version, release]
|
||||
steps:
|
||||
- name: Download All Artifacts
|
||||
run: |
|
||||
mkdir release
|
||||
cd release
|
||||
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-android.apk
|
||||
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-android-x86_64.apk
|
||||
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
|
||||
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
|
||||
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-macos-universal.zip
|
||||
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-win-x86_64.msi
|
||||
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-win-x86_64.zip
|
||||
- name: Upload files to Telegram
|
||||
uses: actions/telegram-send-file@main
|
||||
with:
|
||||
api_url: ${{ secrets.TELEGRAM_API_URL }}
|
||||
chat_ids: |
|
||||
${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
${{ secrets.TELEGRAM_GROUP_ID }}
|
||||
body: '🎁 Release <a href="https://code.gri.mw/${{ forgejo.repository }}/releases">${{ needs.version.outputs.v }}</a> is ready!'
|
||||
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
pin: true
|
||||
files: |
|
||||
release/grim-${{ needs.version.outputs.v }}-android.apk
|
||||
release/grim-${{ needs.version.outputs.v }}-android-x86_64.apk
|
||||
release/grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
|
||||
release/grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
|
||||
release/grim-${{ needs.version.outputs.v }}-macos-universal.zip
|
||||
release/grim-${{ needs.version.outputs.v }}-win-x86_64.msi
|
||||
release/grim-${{ needs.version.outputs.v }}-win-x86_64.zip
|
||||
@@ -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,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
HOST=https://code.gri.mw
|
||||
REPO_NAME=$1
|
||||
TAG=$2
|
||||
DOWNLOAD_URL=${HOST}/${REPO_NAME}/releases/download/${TAG}
|
||||
|
||||
FILES=( "grim-${TAG}-android.apk" "grim-${TAG}-android-x86_64.apk" "grim-${TAG}-linux-arm.AppImage" "grim-${TAG}-linux-x86_64.AppImage" "grim-${TAG}-macos-universal.zip" "grim-${TAG}-win-x86_64.msi" "grim-${TAG}-win-x86_64.zip" )
|
||||
|
||||
# Download release files
|
||||
for f in "${FILES[@]}"; do
|
||||
wget -q ${DOWNLOAD_URL}/${f}
|
||||
echo Downloading ${f}...
|
||||
while [ ! -f ${f} ]; do
|
||||
sleep 5
|
||||
echo Retry ${f}...
|
||||
wget -q ${DOWNLOAD_URL}/${f}
|
||||
done
|
||||
done
|
||||
|
||||
# Save release notes
|
||||
INFO_URL=${HOST}/api/v1/repos/${REPO_NAME}/releases/tags/${TAG}
|
||||
curl -s "${INFO_URL}" | jq -r '.body' > release_notes.txt
|
||||
@@ -1,6 +1,12 @@
|
||||
name: Build
|
||||
on: [push, pull_request]
|
||||
|
||||
# aws-lc-sys (pulled in by nym-sdk) builds AWS-LC, which needs NASM on native
|
||||
# Windows. Use the prebuilt NASM objects the crate ships so the runner doesn't
|
||||
# need NASM installed; harmless on Linux/macOS.
|
||||
env:
|
||||
AWS_LC_SYS_PREBUILT_NASM: 1
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux Build
|
||||
@@ -9,6 +15,8 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -19,9 +27,10 @@ jobs:
|
||||
- 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
|
||||
@@ -29,5 +38,6 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -1,31 +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:
|
||||
create_release:
|
||||
name: Create Release
|
||||
linux:
|
||||
name: Linux x86_64
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
# 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
|
||||
- name: Download release
|
||||
run: chmod +x .github/download_release.sh && .github/download_release.sh GUI/grim ${{ github.ref_name }}
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
body_path: release_notes.txt
|
||||
overwrite_files: true
|
||||
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: |
|
||||
grim-${{ github.ref_name }}-android.apk
|
||||
grim-${{ github.ref_name }}-android-x86_64.apk
|
||||
grim-${{ github.ref_name }}-linux-arm.AppImage
|
||||
grim-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||
grim-${{ github.ref_name }}-macos-universal.zip
|
||||
grim-${{ github.ref_name }}-win-x86_64.msi
|
||||
grim-${{ github.ref_name }}-win-x86_64.zip
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-linux-x86_64.tar.gz
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-linux-x86_64-sha256sum.txt
|
||||
|
||||
windows:
|
||||
name: Windows x86_64 (MSVC)
|
||||
runs-on: windows-latest
|
||||
# Built locally and uploaded with the release; only run on manual dispatch.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
|
||||
- name: 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: |
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64.zip
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64-sha256sum.txt
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Build both architectures
|
||||
run: |
|
||||
export GOBLIN_BUILD="${TAG#build}"
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
cargo build --release --target aarch64-apple-darwin
|
||||
cargo build --release --target x86_64-apple-darwin
|
||||
- name: Universal binary + 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: |
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal.zip
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal-sha256sum.txt
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
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: bash
|
||||
run: sha256sum grim-${{ github.ref_name }}-linux-x86_64.AppImage > 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: bash
|
||||
run: sha256sum grim-${{ github.ref_name }}-linux-arm.AppImage > 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
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
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: bash
|
||||
run: sha256sum grim-${{ github.ref_name }}-win-x86_64.zip > 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: bash
|
||||
run: sha256sum grim-${{ github.ref_name }}-win-x86_64.msi > grim-${{ github.ref_name }}-win-x86_64-msi-sha256sum.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
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
|
||||
|
||||
macos_release:
|
||||
name: MacOS Release
|
||||
runs-on: macos-12
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install coreutils
|
||||
run: brew install coreutils
|
||||
- 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: Download SDK
|
||||
run: wget https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX11.0.sdk.tar.xz
|
||||
- name: Setup SDK env
|
||||
run: tar xf ${{ github.workspace }}/MacOSX11.0.sdk.tar.xz && echo "SDKROOT=${{ github.workspace }}/MacOSX11.0.sdk" >> $GITHUB_ENV
|
||||
- name: Setup platform env
|
||||
run: echo "MACOSX_DEPLOYMENT_TARGET=11.0" >> $GITHUB_ENV
|
||||
- name: Release x86
|
||||
run: |
|
||||
rustup target add x86_64-apple-darwin
|
||||
cargo zigbuild --release --target x86_64-apple-darwin
|
||||
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: bash
|
||||
run: sha256sum grim-${{ github.ref_name }}-macos-x86_64.zip > 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: bash
|
||||
run: sha256sum grim-${{ github.ref_name }}-macos-arm.zip > grim-${{ github.ref_name }}-macos-arm-sha256sum.txt
|
||||
- name: Release Universal
|
||||
run: |
|
||||
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: sha256sum grim-${{ github.ref_name }}-macos-universal.zip > grim-${{ github.ref_name }}-macos-universal-sha256sum.txt
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
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
|
||||
@@ -18,6 +18,9 @@ target
|
||||
.cargo/
|
||||
app/src/main/jniLibs
|
||||
macos/cert.pem
|
||||
linux/Grim.AppDir/AppRun
|
||||
linux/Goblin.AppDir/AppRun
|
||||
.intentionally-empty-file.o
|
||||
Cargo.toml-e
|
||||
Cargo.toml-e
|
||||
screenshots/
|
||||
# GRIM-canonical build toolchains fetched by scripts/toolchain.sh
|
||||
.toolchains/
|
||||
|
||||
@@ -5,7 +5,3 @@
|
||||
path = wallet
|
||||
url = https://code.gri.mw/ardocrat/wallet
|
||||
branch = grim
|
||||
[submodule "tor/webtunnel"]
|
||||
path = tor/webtunnel
|
||||
url = https://code.gri.mw/WEB/webtunnel
|
||||
branch = grim
|
||||
|
||||
@@ -2,21 +2,27 @@
|
||||
name = "grim"
|
||||
version = "0.3.6"
|
||||
authors = ["Ardocrat <ardocrat@gri.mw>"]
|
||||
description = "Cross-platform GUI for Grin with focus on usability and availability to be used by anyone, anywhere."
|
||||
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and the Nym mixnet handled for you."
|
||||
license = "Apache-2.0"
|
||||
repository = "https://code.gri.mw/GUI/grim"
|
||||
keywords = [ "crypto", "grin", "mimblewimble" ]
|
||||
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]
|
||||
strip = true
|
||||
|
||||
[profile.release-apk]
|
||||
inherits = "release"
|
||||
strip = true
|
||||
@@ -87,28 +93,46 @@ 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.42.0", features = ["static", "pt-client", "onion-service-service", "onion-service-client"] }
|
||||
tor-rtcompat = { version = "0.42.0", features = ["static"] }
|
||||
tor-config = "0.42.0"
|
||||
fs-mistrust = "0.14.1"
|
||||
tor-hsservice = "0.42.0"
|
||||
tor-hsrproxy = "0.42.0"
|
||||
tor-keymgr = "0.42.0"
|
||||
tor-llcrypto = "0.42.0"
|
||||
tor-hscrypto = "0.42.0"
|
||||
tor-error = "0.42.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"
|
||||
ed25519-dalek = "2.1.1"
|
||||
curve25519-dalek = "4.1.3"
|
||||
hyper-tor = { version = "0.14.32", features = ["full"], package = "hyper" }
|
||||
tls-api = "0.12.0"
|
||||
tls-api-native-tls = "0.12.1"
|
||||
safelog = "0.8.1"
|
||||
|
||||
## stratum server
|
||||
tokio-old = { version = "0.2", features = ["full"], package = "tokio" }
|
||||
@@ -141,3 +165,10 @@ eframe = { version = "0.33.2", default-features = false, features = ["glow", "an
|
||||
|
||||
[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,39 +1,73 @@
|
||||
# Grim <img height="20" src="img/grin-logo.png"/> <img height="20" src="img/logo.png"/>
|
||||
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
|
||||
## How a payment travels
|
||||
|
||||
To build and run application go to project directory and run:
|
||||
```
|
||||
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/android.sh build|release v7|v8|x86`, where is `v7`, `v8`, `x86` - device CPU architecture for `build` type, for `release` specify version number in format `major.minor.patch`.
|
||||
```
|
||||
./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.
|
||||
|
||||
@@ -8,7 +8,7 @@ android {
|
||||
buildToolsVersion = '36.1.0'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "mw.gri.android"
|
||||
applicationId "st.goblin.wallet"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 5
|
||||
@@ -71,7 +71,9 @@ android {
|
||||
applicationVariants.all { variant ->
|
||||
def flavor = variant.productFlavors[0].name
|
||||
|
||||
if (flavor == "ci") {
|
||||
// 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 {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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"/>
|
||||
@@ -18,7 +19,7 @@
|
||||
android:largeHeap="true"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Grim"
|
||||
android:label="Goblin"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/Theme.Main"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
@@ -29,7 +30,7 @@
|
||||
|
||||
<provider
|
||||
android:name=".FileProvider"
|
||||
android:authorities="mw.gri.android.fileprovider"
|
||||
android:authorities="st.goblin.wallet.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
@@ -40,7 +41,7 @@
|
||||
<activity
|
||||
android:launchMode="singleTask"
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode|density|locale|layoutDirection|fontScale|colorMode"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
|
||||
@@ -366,18 +366,25 @@ public class MainActivity extends GameActivity {
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
unregisterReceiver(mBroadcastReceiver);
|
||||
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();
|
||||
// Only tear the process down when the activity is actually finishing.
|
||||
// onDestroy also fires for configuration-change recreations (rotation,
|
||||
// density, uiMode); killing the process there takes the whole app down
|
||||
// right as Android is about to recreate the activity.
|
||||
if (isFinishing()) {
|
||||
BackgroundService.stop(this);
|
||||
|
||||
// Kill process after 3 secs if app was terminated from recent apps to prevent app hang.
|
||||
new Thread(() -> {
|
||||
try {
|
||||
onTermination();
|
||||
Thread.sleep(3000);
|
||||
Process.killProcess(Process.myPid());
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -496,13 +503,51 @@ public class MainActivity extends GameActivity {
|
||||
// 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("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.
|
||||
public boolean useDarkTheme() {
|
||||
int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||
|
||||
|
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>
|
||||
@@ -1,29 +1,26 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven {
|
||||
credentials {
|
||||
username "$mavenUser"
|
||||
password "$mavenPassword"
|
||||
// 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
|
||||
}
|
||||
}
|
||||
url "$mavenHost/repository/gradle-plugin-portal/"
|
||||
allowInsecureProtocol = true
|
||||
}
|
||||
maven {
|
||||
credentials {
|
||||
username "$mavenUser"
|
||||
password "$mavenPassword"
|
||||
}
|
||||
url "$mavenHost/repository/google-maven/"
|
||||
allowInsecureProtocol = true
|
||||
}
|
||||
maven {
|
||||
credentials {
|
||||
username "$mavenUser"
|
||||
password "$mavenPassword"
|
||||
}
|
||||
url "$mavenHost/repository/maven-central/"
|
||||
allowInsecureProtocol = true
|
||||
} else {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
}
|
||||
include ':app'
|
||||
include ':app'
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::{env, fs};
|
||||
|
||||
/// 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 {}",
|
||||
@@ -23,79 +56,7 @@ fn main() {
|
||||
.expect("failed to execute git config for hooks");
|
||||
}
|
||||
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let tor_out_dir = format!("{}/tor", out_dir);
|
||||
let mut webtunnel_file = format!("{}/webtunnel", tor_out_dir);
|
||||
let exists = fs::exists(&webtunnel_file).unwrap();
|
||||
if !exists {
|
||||
// Create empty webtunnel file to allow build with include_bytes! macro.
|
||||
fs::create_dir(&tor_out_dir).unwrap_or_default();
|
||||
fs::File::create(&webtunnel_file).unwrap();
|
||||
}
|
||||
|
||||
let target = env::var("TARGET").unwrap();
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
if target_os == "ios" {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_android = target_os == "android";
|
||||
if is_android {
|
||||
// Set a path to Android Webtunnel binary.
|
||||
let arch = if target.contains("aarch64") {
|
||||
"arm64-v8a"
|
||||
} else if target.contains("arm") {
|
||||
"armeabi-v7a"
|
||||
} else {
|
||||
"x86_64"
|
||||
};
|
||||
let root = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
webtunnel_file = format!(
|
||||
"{}/android/app/src/main/jniLibs/{}/libwebtunnel.so",
|
||||
root, arch
|
||||
);
|
||||
}
|
||||
|
||||
// Build if Webtunnel binary is empty or not exists.
|
||||
let empty = match fs::File::open(&webtunnel_file) {
|
||||
Ok(file) => file.metadata().unwrap().len() == 0,
|
||||
Err(_) => true,
|
||||
};
|
||||
let build = !exists || empty;
|
||||
if build {
|
||||
// Setup GOOS env variable.
|
||||
let go_os = if target_os == "macos" {
|
||||
"darwin"
|
||||
} else {
|
||||
target_os.as_str()
|
||||
};
|
||||
// Setup GOARCH env variable.
|
||||
let go_arch = if target.contains("aarch64") {
|
||||
"arm64"
|
||||
} else if target.contains("arm") {
|
||||
"arm"
|
||||
} else {
|
||||
"amd64"
|
||||
};
|
||||
// Run Webtunnel Go build.
|
||||
let output = if env::consts::OS == "windows" {
|
||||
Command::new("./scripts/webtunnel.bat")
|
||||
.arg(go_os)
|
||||
.arg(go_arch)
|
||||
.arg(webtunnel_file)
|
||||
.output()
|
||||
} else {
|
||||
Command::new("bash")
|
||||
.arg("./scripts/webtunnel.sh")
|
||||
.arg(go_os)
|
||||
.arg(go_arch)
|
||||
.arg(webtunnel_file)
|
||||
.output()
|
||||
};
|
||||
if let Ok(out) = output {
|
||||
if out.status.code().is_none() || out.status.code().unwrap() != 0 {
|
||||
panic!("webtunnel go build failed:\n{:?}", out);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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: 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 |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1 @@
|
||||
goblin.png
|
||||
@@ -1,7 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Name=Grim
|
||||
Exec=grim
|
||||
Icon=grim
|
||||
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
|
||||
|
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 [platform] [version]\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
|
||||
|
||||
rustup target add ${arch}
|
||||
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$2-linux-$1.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}"
|
||||
|
||||
@@ -49,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:'
|
||||
@@ -180,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
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Grim</string>
|
||||
<string>Goblin</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>grim</string>
|
||||
<string>goblin</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Grim</string>
|
||||
<string>Goblin</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -29,7 +29,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Grim needs an access to your camera to scan QR code.</string>
|
||||
<string>Goblin needs an access to your camera to scan QR code.</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -35,14 +35,14 @@ cargo zigbuild --release --target ${arch}
|
||||
|
||||
rm -f .intentionally-empty-file.o
|
||||
|
||||
yes | cp -rf target/${arch}/release/grim macos/Grim.app/Contents/MacOS
|
||||
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$2-macos-$1.zip
|
||||
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
|
||||
|
||||
@@ -24,6 +24,11 @@ BASEDIR=$(cd "$(dirname "$0")" && pwd)
|
||||
cd "${BASEDIR}" || exit 1
|
||||
cd ..
|
||||
|
||||
# Prefer the GRIM-canonical toolchain: the custom NDK r29 (rebuilt LLVM, 16 KB
|
||||
# page-aligned) + Android SDK from code.gri.mw/DEV. scripts/toolchain.sh fetches
|
||||
# them and writes this env; falls back to whatever NDK/SDK is on the system.
|
||||
[ -f .toolchains/env.sh ] && source .toolchains/env.sh
|
||||
|
||||
# Install platforms and tools
|
||||
rustup target add armv7-linux-androideabi
|
||||
rustup target add aarch64-linux-android
|
||||
@@ -48,6 +53,9 @@ function build_lib() {
|
||||
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
@@ -1,70 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: Change directory to the script's location
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Skip if Go not found.
|
||||
where go >nul 2>nul
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo Go could not be found
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
set "go_os=%~1"
|
||||
set "go_arch=%~2"
|
||||
set "output_path=%~3"
|
||||
|
||||
echo Go build for os: %go_os%, arch: %go_arch%
|
||||
|
||||
:: Setup vars for Android.
|
||||
if "%go_os%"=="android" (
|
||||
|
||||
:: Setup NDK root path env.
|
||||
if "%ANDROID_NDK_HOME%"=="" (
|
||||
:: Extract ndkVersion from build.gradle
|
||||
:: Equivalent to: cat ../android/app/build.gradle | grep 'ndkVersion' | cut -d ' -f 2
|
||||
for /f "tokens=2 delims='" %%a in ('findstr "ndkVersion" ..\android\app\build.gradle') do (
|
||||
set "NDK_VERSION=%%a"
|
||||
)
|
||||
set "ANDROID_NDK_HOME=%ANDROID_HOME%\ndk\!NDK_VERSION!"
|
||||
)
|
||||
|
||||
:: Setup NDK host path.
|
||||
:: Since this is a Batch script, the host is Windows.
|
||||
set "arch_host=windows-x86_64"
|
||||
|
||||
:: Setup NDK target arch.
|
||||
if "%go_arch%"=="arm64" (
|
||||
set "arch_bin_prefix=aarch64-linux-android"
|
||||
) else if "%go_arch%"=="arm" (
|
||||
set "arch_bin_prefix=armv7a-linux-androideabi"
|
||||
) else (
|
||||
set "arch_bin_prefix=x86_64-linux-android"
|
||||
)
|
||||
|
||||
:: Build for current target.
|
||||
set "CGO_ENABLED=1"
|
||||
set "GOOS=%go_os%"
|
||||
set "GOARCH=%go_arch%"
|
||||
|
||||
:: Define CC and CXX paths
|
||||
set "CC=!ANDROID_NDK_HOME!\toolchains\llvm\prebuilt\!arch_host!\bin\!arch_bin_prefix!35-clang"
|
||||
set "CXX=!ANDROID_NDK_HOME!\toolchains\llvm\prebuilt\!arch_host!\bin\!arch_bin_prefix!35-clang++"
|
||||
|
||||
go build -C "../tor/webtunnel" -ldflags="-s -w" -o "%output_path%" code.gri.mw/WEB/webtunnel/main/client
|
||||
|
||||
) else (
|
||||
set "extra_flag="
|
||||
if "%go_os%"=="windows" (
|
||||
set "extra_flag=-H=windowsgui"
|
||||
)
|
||||
|
||||
set "GOOS=%go_os%"
|
||||
set "GOARCH=%go_arch%"
|
||||
|
||||
:: Build for non-android targets
|
||||
go build -C "../tor/webtunnel" -ldflags="-s -w !extra_flag!" -o "%output_path%" code.gri.mw/WEB/webtunnel/main/client
|
||||
)
|
||||
|
||||
endlocal
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Skip if Go not found.
|
||||
if ! command -v go >/dev/null 2>&1
|
||||
then
|
||||
echo "Go could not be found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
go_os=$1
|
||||
go_arch=$2
|
||||
|
||||
echo "Go build for os: $go_os, arch: $go_arch"
|
||||
|
||||
# Setup vars for Android.
|
||||
if [[ "$go_os" == "android" ]]; then
|
||||
# Setup NDK root path env.
|
||||
if [[ -z "$ANDROID_NDK_HOME" ]]; then
|
||||
NDK_VERSION=$(cat ../android/app/build.gradle | grep 'ndkVersion' | cut -d \' -f 2)
|
||||
ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||
fi
|
||||
# Setup NDK host path.
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
arch_host=darwin-x86_64
|
||||
else
|
||||
if [[ "$(uname -m)" == "aarch64" ]]; then
|
||||
arch_host=linux-arm64
|
||||
else
|
||||
arch_host=linux-x86_64
|
||||
fi
|
||||
fi
|
||||
# Setup NDK target arch.
|
||||
if [[ "$go_arch" == "arm64" ]]; then
|
||||
arch_bin_prefix=aarch64-linux-android
|
||||
elif [[ "$go_arch" == "arm" ]]; then
|
||||
arch_bin_prefix=armv7a-linux-androideabi
|
||||
else
|
||||
arch_bin_prefix=x86_64-linux-android
|
||||
fi
|
||||
|
||||
# Build for current target.
|
||||
CGO_ENABLED=1 GOOS=$1 GOARCH=$2 CC="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${arch_host}/bin/${arch_bin_prefix}35-clang" CXX="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${arch_path}/bin/${arch_bin_prefix}35-clang++" go build -C "../tor/webtunnel" -ldflags="-s -w" -o "$3" code.gri.mw/WEB/webtunnel/main/client
|
||||
else
|
||||
if [[ "$go_os" == "windows" ]]; then
|
||||
extra_flag="-H=windowsgui"
|
||||
fi
|
||||
GOOS=$1 GOARCH=$2 go build -C "../tor/webtunnel" -ldflags="-s -w ${extra_flag}" -o "$3" code.gri.mw/WEB/webtunnel/main/client
|
||||
fi
|
||||
|
||||
@@ -44,6 +44,8 @@ pub struct App<Platform> {
|
||||
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> {
|
||||
@@ -53,6 +55,7 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
content: Content::default(),
|
||||
resize_direction: None,
|
||||
first_draw: true,
|
||||
status_bar_white: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +77,14 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
self.first_draw = false;
|
||||
}
|
||||
|
||||
// Keep the Android status-bar icons readable against the in-app theme
|
||||
// (the app draws edge-to-edge over the status bar). Only on change.
|
||||
let white_icons = crate::gui::theme::status_bar_white_icons();
|
||||
if self.status_bar_white != Some(white_icons) {
|
||||
self.platform.set_status_bar_white_icons(white_icons);
|
||||
self.status_bar_white = Some(white_icons);
|
||||
}
|
||||
|
||||
// Handle Esc keyboard key event and platform Back button key event.
|
||||
let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed);
|
||||
if back_pressed
|
||||
@@ -167,6 +178,28 @@ impl<Platform: PlatformCallbacks> App<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 {
|
||||
@@ -291,7 +324,7 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
}
|
||||
|
||||
// Paint the title.
|
||||
let title_text = format!("Grim {} ツ", crate::VERSION);
|
||||
let title_text = format!("Goblin ツ · Build {}", crate::BUILD);
|
||||
painter.text(
|
||||
title_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
|
||||
@@ -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,95 +13,39 @@
|
||||
// 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 GOLD_DARK: Color32 = Color32::from_rgb(240, 203, 1);
|
||||
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 GREEN_DARK: Color32 = Color32::from_rgb(0, (0x64 as f32 * 1.3 + 0.5) as u8, 0);
|
||||
|
||||
const RED: Color32 = Color32::from_rgb(0x8B, 0, 0);
|
||||
const RED_DARK: Color32 = Color32::from_rgb((0x8B as f32 * 1.3 + 0.5) as u8, 50, 30);
|
||||
|
||||
const BLUE: Color32 = Color32::from_rgb(0, 0x66, 0xE4);
|
||||
const BLUE_DARK: Color32 = Color32::from_rgb(
|
||||
0,
|
||||
(0x66 as f32 * 1.3 + 0.5) as u8,
|
||||
(0xE4 as f32 * 1.3 + 0.5) as u8,
|
||||
);
|
||||
|
||||
const FILL: Color32 = Color32::from_gray(244);
|
||||
const FILL_DARK: Color32 = Color32::from_gray(26);
|
||||
|
||||
const FILL_DEEP_DARK: Color32 = Color32::from_gray(32);
|
||||
|
||||
const FILL_LITE: Color32 = Color32::from_gray(249);
|
||||
const FILL_LITE_DARK: Color32 = Color32::from_gray(21);
|
||||
|
||||
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 GRAY: Color32 = Color32::from_gray(120);
|
||||
const GRAY_DARK: Color32 = Color32::from_gray(145);
|
||||
|
||||
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 FILL_DEEP: Color32 = Color32::from_gray(238);
|
||||
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_gray(200);
|
||||
pub const STROKE: Color32 = Color32::from_rgba_premultiplied(1, 1, 1, 20);
|
||||
|
||||
/// Ink when `true`, paper when `false` (theme aware: maps to text/bg).
|
||||
pub fn white_or_black(black_in_white: bool) -> Color32 {
|
||||
if use_dark() {
|
||||
if black_in_white { WHITE } else { BLACK }
|
||||
} else {
|
||||
if black_in_white { BLACK } else { WHITE }
|
||||
}
|
||||
let t = theme::tokens();
|
||||
if black_in_white { t.text } else { t.bg }
|
||||
}
|
||||
|
||||
pub fn semi_transparent() -> Color32 {
|
||||
if use_dark() {
|
||||
if dark_base() {
|
||||
DARK_SEMI_TRANSPARENT
|
||||
} else {
|
||||
SEMI_TRANSPARENT
|
||||
@@ -108,130 +53,115 @@ impl Colors {
|
||||
}
|
||||
|
||||
pub fn gold() -> Color32 {
|
||||
if use_dark() {
|
||||
GOLD.gamma_multiply(0.9)
|
||||
} else {
|
||||
GOLD
|
||||
}
|
||||
theme::tokens().accent
|
||||
}
|
||||
|
||||
pub fn gold_dark() -> Color32 {
|
||||
if use_dark() {
|
||||
GOLD_DARK.gamma_multiply(0.9)
|
||||
} else {
|
||||
GOLD_DARK
|
||||
}
|
||||
theme::tokens().accent_dark
|
||||
}
|
||||
|
||||
pub fn yellow() -> Color32 {
|
||||
YELLOW
|
||||
theme::tokens().accent
|
||||
}
|
||||
|
||||
pub fn yellow_dark() -> Color32 {
|
||||
YELLOW_DARK
|
||||
theme::tokens().accent_dark
|
||||
}
|
||||
|
||||
/// Ink color to draw on top of accent fills.
|
||||
pub fn accent_ink() -> Color32 {
|
||||
theme::tokens().accent_ink
|
||||
}
|
||||
|
||||
pub fn green() -> Color32 {
|
||||
if use_dark() { GREEN_DARK } else { GREEN }
|
||||
theme::tokens().pos
|
||||
}
|
||||
|
||||
pub fn red() -> Color32 {
|
||||
if use_dark() { RED_DARK } else { RED }
|
||||
theme::tokens().neg
|
||||
}
|
||||
|
||||
pub fn blue() -> Color32 {
|
||||
if use_dark() { BLUE_DARK } else { BLUE }
|
||||
if dark_base() {
|
||||
Color32::from_rgb(0x7B, 0xA7, 0xFF)
|
||||
} else {
|
||||
Color32::from_rgb(0x0E, 0x62, 0xD0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fill() -> Color32 {
|
||||
if use_dark() { FILL_DARK } else { FILL }
|
||||
theme::tokens().bg
|
||||
}
|
||||
|
||||
pub fn fill_deep() -> Color32 {
|
||||
if use_dark() {
|
||||
FILL_DEEP_DARK
|
||||
} else {
|
||||
Self::FILL_DEEP
|
||||
}
|
||||
theme::tokens().surface2
|
||||
}
|
||||
|
||||
pub fn fill_lite() -> Color32 {
|
||||
if use_dark() {
|
||||
FILL_LITE_DARK
|
||||
} else {
|
||||
FILL_LITE
|
||||
}
|
||||
theme::tokens().surface
|
||||
}
|
||||
|
||||
pub fn checkbox() -> Color32 {
|
||||
if use_dark() { CHECKBOX_DARK } else { CHECKBOX }
|
||||
theme::tokens().text_dim
|
||||
}
|
||||
|
||||
pub fn text(always_light: bool) -> Color32 {
|
||||
if use_dark() && !always_light {
|
||||
TEXT_DARK
|
||||
if always_light {
|
||||
// Forced light-theme ink, used over always-light surfaces like QR cards.
|
||||
Color32::from_rgb(0x6B, 0x6A, 0x63)
|
||||
} else {
|
||||
TEXT
|
||||
theme::tokens().text_dim
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_button() -> Color32 {
|
||||
if use_dark() {
|
||||
TEXT_BUTTON_DARK
|
||||
} else {
|
||||
TEXT_BUTTON
|
||||
}
|
||||
theme::tokens().text
|
||||
}
|
||||
|
||||
pub fn title(always_light: bool) -> Color32 {
|
||||
if use_dark() && !always_light {
|
||||
TITLE_DARK
|
||||
if always_light {
|
||||
INK
|
||||
} else {
|
||||
TITLE
|
||||
theme::tokens().text
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gray() -> Color32 {
|
||||
if use_dark() { GRAY_DARK } else { GRAY }
|
||||
theme::tokens().text_mute
|
||||
}
|
||||
|
||||
pub fn stroke() -> Color32 {
|
||||
if use_dark() {
|
||||
STROKE_DARK
|
||||
} else {
|
||||
Self::STROKE
|
||||
}
|
||||
theme::tokens().line
|
||||
}
|
||||
|
||||
pub fn inactive_text() -> Color32 {
|
||||
if use_dark() {
|
||||
INACTIVE_TEXT_DARK
|
||||
} else {
|
||||
INACTIVE_TEXT
|
||||
}
|
||||
theme::tokens().text_mute
|
||||
}
|
||||
|
||||
pub fn item_button_text() -> Color32 {
|
||||
if use_dark() {
|
||||
ITEM_BUTTON_DARK
|
||||
} else {
|
||||
ITEM_BUTTON
|
||||
}
|
||||
theme::tokens().text_dim
|
||||
}
|
||||
|
||||
pub fn item_stroke() -> Color32 {
|
||||
if use_dark() {
|
||||
ITEM_STROKE_DARK
|
||||
} else {
|
||||
ITEM_STROKE
|
||||
}
|
||||
theme::tokens().line
|
||||
}
|
||||
|
||||
pub fn item_hover() -> Color32 {
|
||||
if use_dark() {
|
||||
ITEM_HOVER_DARK
|
||||
} else {
|
||||
ITEM_HOVER
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub use app::App;
|
||||
mod colors;
|
||||
pub use colors::Colors;
|
||||
|
||||
pub mod theme;
|
||||
|
||||
pub mod icons;
|
||||
pub mod platform;
|
||||
pub mod views;
|
||||
|
||||
@@ -158,6 +158,19 @@ impl PlatformCallbacks for Android {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn share_text(&self, text: String) {
|
||||
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
||||
let env = vm.attach_current_thread().unwrap();
|
||||
let Ok(arg_value) = env.new_string(text) else {
|
||||
return;
|
||||
};
|
||||
let _ = self.call_java_method(
|
||||
"shareText",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(&JObject::from(arg_value))],
|
||||
);
|
||||
}
|
||||
|
||||
fn pick_file(&self) -> Option<String> {
|
||||
// Clear previous result.
|
||||
let mut w_path = PICKED_FILE_PATH.write();
|
||||
@@ -199,6 +212,18 @@ impl PlatformCallbacks for Android {
|
||||
}
|
||||
|
||||
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! {
|
||||
|
||||
@@ -62,17 +62,22 @@ impl Desktop {
|
||||
use nokhwa::Camera;
|
||||
use nokhwa::pixel_format::RgbFormat;
|
||||
use nokhwa::utils::ApiBackend;
|
||||
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
|
||||
|
||||
let devices = nokhwa::query(ApiBackend::Auto).unwrap();
|
||||
cameras_amount.store(devices.len(), Ordering::Relaxed);
|
||||
let index = camera_index.load(Ordering::Relaxed);
|
||||
if devices.is_empty() || index >= devices.len() {
|
||||
return;
|
||||
}
|
||||
use nokhwa::utils::{FrameFormat, RequestedFormat, RequestedFormatType};
|
||||
|
||||
thread::spawn(move || {
|
||||
let index = CameraIndex::Index(camera_index.load(Ordering::Relaxed) as u32);
|
||||
// Device enumeration does IO — keep it off the UI thread, and
|
||||
// treat a backend error the same as "no cameras".
|
||||
let devices = nokhwa::query(ApiBackend::Auto).unwrap_or_default();
|
||||
cameras_amount.store(devices.len(), Ordering::Relaxed);
|
||||
let index = camera_index.load(Ordering::Relaxed);
|
||||
if devices.is_empty() || index >= devices.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open by the enumerated device's own index, not the list
|
||||
// position: on v4l they differ whenever /dev/video0 is absent
|
||||
// (first camera at video1, loopback-only setups, …).
|
||||
let index = devices[index].index().clone();
|
||||
let requested =
|
||||
RequestedFormat::new::<RgbFormat>(RequestedFormatType::AbsoluteHighestFrameRate);
|
||||
// Create and open camera.
|
||||
@@ -89,9 +94,29 @@ impl Desktop {
|
||||
}
|
||||
// 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));
|
||||
// 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();
|
||||
@@ -99,7 +124,7 @@ impl Desktop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
camera.stop_stream().unwrap();
|
||||
let _ = camera.stop_stream();
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -267,6 +292,15 @@ impl PlatformCallbacks for Desktop {
|
||||
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"))
|
||||
|
||||
@@ -32,10 +32,29 @@ pub trait PlatformCallbacks {
|
||||
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()
|
||||
}
|
||||
@@ -36,6 +36,8 @@ pub struct CameraContent {
|
||||
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 {
|
||||
@@ -43,6 +45,7 @@ impl Default for CameraContent {
|
||||
Self {
|
||||
qr_scan_state: Arc::new(RwLock::new(QrScanState::default())),
|
||||
ur_data: Arc::new(RwLock::new(None)),
|
||||
wait_start: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,8 +156,24 @@ impl CameraContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw camera loading progress content.
|
||||
/// 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);
|
||||
@@ -448,3 +467,62 @@ impl CameraContent {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,25 @@ impl ContentContainer for Content {
|
||||
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")
|
||||
@@ -102,7 +121,7 @@ impl ContentContainer for Content {
|
||||
.frame(egui::Frame {
|
||||
..Default::default()
|
||||
})
|
||||
.show_animated_inside(ui, is_panel_open, |ui| {
|
||||
.show_animated_inside(ui, show_network, |ui| {
|
||||
self.network.ui(ui, cb);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>"##
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
// Copyright 2026 The Goblin Developers
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! First-run onboarding: what Goblin is → node choice → wallet create or
|
||||
//! restore → optional payment-identity username. Wraps GRIM's mnemonic and
|
||||
//! wallet-creation machinery without replacing it — the stock creation flow
|
||||
//! stays available from the wallet list for later wallets.
|
||||
|
||||
use eframe::epaint::FontId;
|
||||
use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2};
|
||||
use grin_util::ZeroingString;
|
||||
|
||||
use crate::gui::icons::ARROW_LEFT;
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::theme::{self, fonts};
|
||||
use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult};
|
||||
use crate::gui::views::wallets::creation::MnemonicSetup;
|
||||
use crate::gui::views::{CameraScanContent, Content, Modal, TextEdit, View};
|
||||
use crate::node::Node;
|
||||
use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize};
|
||||
use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList};
|
||||
|
||||
use super::widgets::{self as w};
|
||||
use super::{ClaimMsg, ClaimState, start_claim_flow};
|
||||
|
||||
/// Identifier for the recovery-phrase QR scan [`Modal`].
|
||||
const OB_PHRASE_SCAN_MODAL: &'static str = "ob_phrase_scan_modal";
|
||||
|
||||
/// Onboarding step.
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
enum Step {
|
||||
Intro,
|
||||
Node,
|
||||
WalletSetup,
|
||||
Words,
|
||||
ConfirmWords,
|
||||
Identity,
|
||||
}
|
||||
|
||||
/// First-run onboarding content.
|
||||
pub struct OnboardingContent {
|
||||
step: Step,
|
||||
/// Node choice: integrated (own node) or external URL.
|
||||
integrated: bool,
|
||||
ext_url: String,
|
||||
/// Wallet setup inputs.
|
||||
restore: bool,
|
||||
name: String,
|
||||
pass: String,
|
||||
pass2: String,
|
||||
/// GRIM's mnemonic machinery (word grid, validation, import).
|
||||
mnemonic_setup: MnemonicSetup,
|
||||
/// Wallet creation error, if any.
|
||||
error: Option<String>,
|
||||
/// QR scanner for recovery phrase import.
|
||||
scan_modal: Option<CameraScanContent>,
|
||||
/// Created and opened wallet, present from the Identity step on.
|
||||
wallet: Option<Wallet>,
|
||||
/// Optional username claim state (same machinery as Settings).
|
||||
claim: ClaimState,
|
||||
}
|
||||
|
||||
impl Default for OnboardingContent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
step: Step::Intro,
|
||||
integrated: true,
|
||||
ext_url: "https://grincoin.org".to_string(),
|
||||
restore: false,
|
||||
name: "Main wallet".to_string(),
|
||||
pass: String::new(),
|
||||
pass2: String::new(),
|
||||
mnemonic_setup: MnemonicSetup::default(),
|
||||
error: None,
|
||||
scan_modal: None,
|
||||
wallet: None,
|
||||
claim: ClaimState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OnboardingContent {
|
||||
/// Render onboarding. Returns the wallet once the user finishes the
|
||||
/// final step, so the host can select it and drop this content.
|
||||
pub fn ui(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
wallets: &mut WalletList,
|
||||
cb: &dyn PlatformCallbacks,
|
||||
) -> Option<Wallet> {
|
||||
// Draw owned modals (word input, phrase scan) when opened.
|
||||
if let Some(id) = Modal::opened() {
|
||||
if id == OB_PHRASE_SCAN_MODAL {
|
||||
Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
|
||||
self.scan_modal_ui(ui, modal, cb);
|
||||
});
|
||||
} else if self.mnemonic_setup.modal_ids().contains(&id) {
|
||||
Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
|
||||
self.mnemonic_setup.modal_ui(ui, modal, cb);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut done = None;
|
||||
ScrollArea::vertical()
|
||||
.id_salt("goblin_onboarding")
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
|
||||
ui.add_space(View::get_top_inset() + 24.0);
|
||||
match self.step {
|
||||
Step::Intro => self.intro_ui(ui),
|
||||
Step::Node => self.node_ui(ui, cb),
|
||||
Step::WalletSetup => self.wallet_setup_ui(ui, cb),
|
||||
Step::Words => self.words_ui(ui, wallets, cb),
|
||||
Step::ConfirmWords => self.confirm_ui(ui, wallets, cb),
|
||||
Step::Identity => done = self.identity_ui(ui, cb),
|
||||
}
|
||||
ui.add_space(View::get_bottom_inset() + 24.0);
|
||||
});
|
||||
});
|
||||
done
|
||||
}
|
||||
|
||||
/// Back chip + step kicker shared by all steps after the intro.
|
||||
fn step_header(&mut self, ui: &mut egui::Ui, kicker: &str, title: &str, back: Step) {
|
||||
let t = theme::tokens();
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
|
||||
ui.painter().circle_filled(rect.center(), 18.0, t.surface2);
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
ARROW_LEFT,
|
||||
FontId::new(16.0, fonts::regular()),
|
||||
t.surface_text,
|
||||
);
|
||||
if resp
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
self.error = None;
|
||||
self.step = back;
|
||||
}
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(kicker)
|
||||
.font(fonts::kicker())
|
||||
.color(t.text_mute),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
ui.label(
|
||||
RichText::new(title)
|
||||
.font(FontId::new(26.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
|
||||
// ── Intro ────────────────────────────────────────────────────────────
|
||||
|
||||
fn intro_ui(&mut self, ui: &mut egui::Ui) {
|
||||
let t = theme::tokens();
|
||||
ui.add_space(26.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
super::widgets_logo_sized(ui, 72.0);
|
||||
ui.add_space(14.0);
|
||||
ui.label(
|
||||
RichText::new("goblin")
|
||||
.font(FontId::new(34.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
});
|
||||
ui.add_space(26.0);
|
||||
let lines: [(&str, &str); 3] = [
|
||||
(
|
||||
"Private money",
|
||||
"Goblin is a wallet for grin — digital cash with no amounts \
|
||||
or addresses on its chain.",
|
||||
),
|
||||
(
|
||||
"Send like a message",
|
||||
"Pay a @username or npub and it arrives as an end-to-end \
|
||||
encrypted message over nostr and the Nym mixnet — no one in \
|
||||
between can see the amount or who's involved.",
|
||||
),
|
||||
(
|
||||
"Yours alone",
|
||||
"Keys, names and history live on this device. Built on the \
|
||||
GRIM wallet.",
|
||||
),
|
||||
];
|
||||
for (head, body) in lines {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.label(
|
||||
RichText::new(head)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(body)
|
||||
.font(FontId::new(13.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
if w::big_action(ui, "Get started", false).clicked() {
|
||||
self.step = Step::Node;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Takes about a minute. You can change everything later.")
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Node choice ──────────────────────────────────────────────────────
|
||||
|
||||
fn node_card(ui: &mut egui::Ui, selected: bool, title: &str, word: &str, body: &str) -> bool {
|
||||
let t = theme::tokens();
|
||||
let resp = ui
|
||||
.scope(|ui| {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let (dot, _) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::hover());
|
||||
ui.painter().circle_stroke(
|
||||
dot.center(),
|
||||
8.0,
|
||||
eframe::epaint::Stroke::new(1.5, t.surface_text_mute),
|
||||
);
|
||||
if selected {
|
||||
ui.painter().circle_filled(dot.center(), 5.0, t.accent);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(title)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let galley = ui.painter().layout_no_wrap(
|
||||
word.to_string(),
|
||||
FontId::new(12.0, fonts::semibold()),
|
||||
t.bg,
|
||||
);
|
||||
let pad = Vec2::new(10.0, 5.0);
|
||||
let (rect, _) =
|
||||
ui.allocate_exact_size(galley.size() + pad * 2.0, Sense::hover());
|
||||
ui.painter().rect_filled(
|
||||
rect,
|
||||
eframe::epaint::CornerRadius::same(10),
|
||||
t.accent,
|
||||
);
|
||||
ui.painter().galley(rect.min + pad, galley, t.bg);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(body)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
})
|
||||
.response;
|
||||
resp.interact(Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
}
|
||||
|
||||
fn node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
let t = theme::tokens();
|
||||
self.step_header(
|
||||
ui,
|
||||
"STEP 1 OF 3 · NETWORK",
|
||||
"How should Goblin\nwatch the chain?",
|
||||
Step::Intro,
|
||||
);
|
||||
if Self::node_card(
|
||||
ui,
|
||||
self.integrated,
|
||||
"Run my own node",
|
||||
"Private",
|
||||
"Trusts no one — your wallet checks the chain itself. Syncs in \
|
||||
the background while you finish setup.",
|
||||
) {
|
||||
self.integrated = true;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if Self::node_card(
|
||||
ui,
|
||||
!self.integrated,
|
||||
"Connect to a node",
|
||||
"Instant",
|
||||
"No sync wait. The node you pick can see your wallet's queries.",
|
||||
) {
|
||||
self.integrated = false;
|
||||
}
|
||||
if !self.integrated {
|
||||
ui.add_space(10.0);
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_ext_url"))
|
||||
.focus(false)
|
||||
.hint_text("https://node.example.com")
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.ext_url, cb);
|
||||
});
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Changeable any time in Settings → Node.")
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
let url_ok = self.integrated
|
||||
|| self.ext_url.trim().starts_with("http://")
|
||||
|| self.ext_url.trim().starts_with("https://");
|
||||
if w::big_action(ui, "Continue", false).clicked() && url_ok {
|
||||
self.step = Step::WalletSetup;
|
||||
}
|
||||
if !url_ok {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Node URL must start with http:// or https://")
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wallet name + password, create vs restore ───────────────────────
|
||||
|
||||
fn wallet_setup_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
let t = theme::tokens();
|
||||
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Set up your wallet", Step::Node);
|
||||
|
||||
// Create / Restore segmented choice.
|
||||
ui.horizontal(|ui| {
|
||||
let half = (ui.available_width() - 10.0) / 2.0;
|
||||
for (restore, label) in [(false, "Create new"), (true, "Restore from seed")] {
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
let active = self.restore == restore;
|
||||
let resp = w::chip(ui, label, active);
|
||||
if resp.clicked() {
|
||||
self.restore = restore;
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_name"))
|
||||
.focus(false)
|
||||
.hint_text("Wallet name")
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.name, cb);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_pass"))
|
||||
.focus(false)
|
||||
.hint_text("Password")
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.pass, cb);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_pass2"))
|
||||
.focus(false)
|
||||
.hint_text("Repeat password")
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.pass2, cb);
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
RichText::new(if self.restore {
|
||||
"Have your seed words ready — you'll enter them next."
|
||||
} else {
|
||||
"Next you'll get 24 seed words to write down. They are the \
|
||||
money — anyone holding them holds your funds."
|
||||
})
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
|
||||
let pass_ok = !self.pass.is_empty() && self.pass == self.pass2;
|
||||
let name_ok = !self.name.trim().is_empty();
|
||||
if w::big_action(ui, "Continue", false).clicked() && pass_ok && name_ok {
|
||||
self.mnemonic_setup.reset();
|
||||
self.mnemonic_setup.mnemonic.set_mode(if self.restore {
|
||||
PhraseMode::Import
|
||||
} else {
|
||||
PhraseMode::Generate
|
||||
});
|
||||
self.mnemonic_setup.mnemonic.set_size(PhraseSize::Words24);
|
||||
self.error = None;
|
||||
self.step = Step::Words;
|
||||
}
|
||||
if !self.pass.is_empty() && self.pass != self.pass2 {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Passwords don't match")
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seed words (display for create, entry for restore) ──────────────
|
||||
|
||||
fn words_ui(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
wallets: &mut WalletList,
|
||||
cb: &dyn PlatformCallbacks,
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
let restore = self.mnemonic_setup.mnemonic.mode() == PhraseMode::Import;
|
||||
self.step_header(
|
||||
ui,
|
||||
"STEP 2 OF 3 · WALLET",
|
||||
if restore {
|
||||
"Enter your seed words"
|
||||
} else {
|
||||
"Write these words down"
|
||||
},
|
||||
Step::WalletSetup,
|
||||
);
|
||||
if restore {
|
||||
// Word count picker for restores.
|
||||
ui.horizontal(|ui| {
|
||||
for size in PhraseSize::VALUES {
|
||||
let label = format!("{}", size.value());
|
||||
let active = self.mnemonic_setup.mnemonic.size() == size;
|
||||
if w::chip(ui, &label, active).clicked() {
|
||||
self.mnemonic_setup.mnemonic.set_size(size);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"On paper, in order. Anyone with these words can take \
|
||||
your funds; without them a lost device means lost funds.",
|
||||
)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
// GRIM's word grid (edit mode when restoring).
|
||||
self.mnemonic_setup.word_list_ui(ui, restore);
|
||||
ui.add_space(14.0);
|
||||
|
||||
if restore {
|
||||
ui.horizontal(|ui| {
|
||||
let half = (ui.available_width() - 10.0) / 2.0;
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::chip(ui, "Paste", false).clicked() {
|
||||
let data = ZeroingString::from(cb.get_string_from_buffer());
|
||||
self.mnemonic_setup.mnemonic.import(&data);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::chip(ui, "Scan QR", false).clicked() {
|
||||
self.scan_modal = Some(CameraScanContent::default());
|
||||
Modal::new(OB_PHRASE_SCAN_MODAL)
|
||||
.position(ModalPosition::CenterTop)
|
||||
.title(t!("scan_qr"))
|
||||
.closeable(false)
|
||||
.show();
|
||||
cb.start_camera();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
} else if w::chip(ui, "Copy to clipboard (avoid this)", false).clicked() {
|
||||
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
|
||||
}
|
||||
if !restore {
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
|
||||
let ready = if restore {
|
||||
!self.mnemonic_setup.mnemonic.has_empty_or_invalid()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
let label = if restore {
|
||||
"Restore wallet"
|
||||
} else {
|
||||
"I wrote them down"
|
||||
};
|
||||
if ready {
|
||||
if w::big_action(ui, label, false).clicked() {
|
||||
if restore {
|
||||
self.create_wallet(wallets);
|
||||
} else {
|
||||
self.step = Step::ConfirmWords;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("Fill every word — tap a word to edit it, or paste the phrase.")
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
}
|
||||
self.error_ui(ui);
|
||||
}
|
||||
|
||||
fn confirm_ui(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
wallets: &mut WalletList,
|
||||
cb: &dyn PlatformCallbacks,
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Now prove it", Step::Words);
|
||||
ui.label(
|
||||
RichText::new("Enter the words you just wrote down. Tap a word to type it.")
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
self.mnemonic_setup.word_list_ui(ui, true);
|
||||
ui.add_space(14.0);
|
||||
if w::chip(ui, "Paste", false).clicked() {
|
||||
let data = ZeroingString::from(cb.get_string_from_buffer());
|
||||
self.mnemonic_setup.mnemonic.import(&data);
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
if !self.mnemonic_setup.mnemonic.has_empty_or_invalid() {
|
||||
if w::big_action(ui, "Create wallet", false).clicked() {
|
||||
self.create_wallet(wallets);
|
||||
}
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("Keep going — every word, in order.")
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
}
|
||||
self.error_ui(ui);
|
||||
}
|
||||
|
||||
fn error_ui(&self, ui: &mut egui::Ui) {
|
||||
if let Some(err) = &self.error {
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
RichText::new(err)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(theme::tokens().neg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the connection method, create the wallet, open it and move
|
||||
/// to the identity step.
|
||||
fn create_wallet(&mut self, wallets: &mut WalletList) {
|
||||
// Connection: integrated starts the local node; external reuses an
|
||||
// existing saved connection with the same URL or saves a new one.
|
||||
let method = if self.integrated {
|
||||
if !Node::is_running() {
|
||||
Node::start();
|
||||
}
|
||||
ConnectionMethod::Integrated
|
||||
} else {
|
||||
let url = self.ext_url.trim().trim_end_matches('/').to_string();
|
||||
let existing = ConnectionsConfig::ext_conn_list()
|
||||
.into_iter()
|
||||
.find(|c| c.url.trim_end_matches('/') == url);
|
||||
let conn = match existing {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let c = ExternalConnection::new(url, None, None);
|
||||
ConnectionsConfig::add_ext_conn(c.clone());
|
||||
c
|
||||
}
|
||||
};
|
||||
ConnectionMethod::External(conn.id, conn.url.clone())
|
||||
};
|
||||
|
||||
let pass = ZeroingString::from(self.pass.clone());
|
||||
match Wallet::create(
|
||||
&self.name.trim().to_string(),
|
||||
&pass,
|
||||
&self.mnemonic_setup.mnemonic,
|
||||
&method,
|
||||
) {
|
||||
Ok(w) => {
|
||||
self.mnemonic_setup.reset();
|
||||
wallets.add(w.clone());
|
||||
match w.open(pass) {
|
||||
Ok(_) => {
|
||||
self.wallet = Some(w);
|
||||
self.error = None;
|
||||
self.step = Step::Identity;
|
||||
}
|
||||
Err(e) => self.error = Some(format!("Couldn't open the wallet: {:?}", e)),
|
||||
}
|
||||
}
|
||||
Err(e) => self.error = Some(format!("Couldn't create the wallet: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity (optional username) ─────────────────────────────────────
|
||||
|
||||
fn identity_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) -> Option<Wallet> {
|
||||
let t = theme::tokens();
|
||||
// No back from here: the wallet exists now.
|
||||
ui.label(
|
||||
RichText::new("STEP 3 OF 3 · IDENTITY")
|
||||
.font(fonts::kicker())
|
||||
.color(t.text_mute),
|
||||
);
|
||||
ui.add_space(18.0);
|
||||
ui.label(
|
||||
RichText::new("Your payment identity")
|
||||
.font(FontId::new(26.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
|
||||
let wallet = self.wallet.clone()?;
|
||||
let service = wallet.nostr_service();
|
||||
let (npub, connected) = service
|
||||
.as_ref()
|
||||
.map(|s| (s.npub(), s.is_connected()))
|
||||
.unwrap_or((String::new(), false));
|
||||
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
// Same deterministic gradient + Grin mark the rest of the app shows
|
||||
// for this key; only fall back to a placeholder while the key is
|
||||
// still being generated (npub not yet available).
|
||||
if npub.is_empty() {
|
||||
w::avatar(ui, "N", 44.0, 6);
|
||||
} else {
|
||||
w::gradient_avatar(ui, &npub, 44.0);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.vertical(|ui| {
|
||||
let short = if npub.len() > 20 {
|
||||
format!("{}…{}", &npub[..12], &npub[npub.len() - 6..])
|
||||
} else if npub.is_empty() {
|
||||
"key being made…".to_string()
|
||||
} else {
|
||||
npub.clone()
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(short)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(if connected {
|
||||
"connected over Nym"
|
||||
} else {
|
||||
"connecting over Nym…"
|
||||
})
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"A fresh key, made for payments — deliberately not part \
|
||||
of your seed, so you can rotate it anytime to maintain \
|
||||
your privacy, without ever touching your funds. Back it \
|
||||
up in Settings → Identity.",
|
||||
)
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Want a clean slate? Swap in a brand-new key any time — \
|
||||
the new you isn't linked to the old one. Same wallet, \
|
||||
fresh face.",
|
||||
)
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
|
||||
// Optional username claim — the same machinery as Settings.
|
||||
if let Some(msg) = self.claim.result.lock().unwrap().take() {
|
||||
self.claim.checking = false;
|
||||
match msg {
|
||||
ClaimMsg::Availability(avail) => {
|
||||
let (available, msg) = super::availability_feedback(avail);
|
||||
self.claim.available = available;
|
||||
self.claim.message = Some(msg.to_string());
|
||||
}
|
||||
ClaimMsg::Registered(nip05) => {
|
||||
self.claim.message =
|
||||
Some(format!("You're @{}", nip05.split('@').next().unwrap_or("")));
|
||||
self.claim.available = Some(true);
|
||||
if let Some(s) = wallet.nostr_service() {
|
||||
{
|
||||
let mut id = s.identity.write();
|
||||
id.nip05 = Some(nip05.clone());
|
||||
id.anonymous = false;
|
||||
}
|
||||
s.save_identity();
|
||||
}
|
||||
}
|
||||
ClaimMsg::Released => {}
|
||||
ClaimMsg::Error(e) => {
|
||||
self.claim.available = Some(false);
|
||||
self.claim.message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let registered = wallet
|
||||
.nostr_service()
|
||||
.map(|s| s.identity.read().nip05.is_some())
|
||||
.unwrap_or(false);
|
||||
if !registered {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.label(
|
||||
RichText::new("Pick a username — optional")
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Friends pay @you instead of a long key. Public on \
|
||||
goblin.st; payments stay encrypted. Skip it and \
|
||||
you're simply anonymous — claim one any time later.",
|
||||
)
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
w::field_well(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new("@")
|
||||
.font(FontId::new(16.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
let before = self.claim.input.clone();
|
||||
TextEdit::new(egui::Id::from("onb_claim"))
|
||||
.focus(false)
|
||||
.hint_text("yourname")
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.claim.input, cb);
|
||||
if self.claim.input != before {
|
||||
self.claim.available = None;
|
||||
self.claim.message = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(msg) = &self.claim.message {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(msg)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(match self.claim.available {
|
||||
Some(false) => t.neg,
|
||||
Some(true) => t.pos,
|
||||
None => t.surface_text_dim,
|
||||
}),
|
||||
);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let name = self.claim.input.trim().to_lowercase();
|
||||
let valid = name.len() >= 3 && name.len() <= 30;
|
||||
if self.claim.checking {
|
||||
ui.horizontal(|ui| {
|
||||
View::small_loading_spinner(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new("Working…").color(t.surface_text_dim));
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
} else {
|
||||
ui.add_enabled_ui(valid && connected, |ui| {
|
||||
if w::big_action_on_card(ui, "Claim username").clicked() {
|
||||
start_claim_flow(&mut self.claim, &name, &wallet);
|
||||
}
|
||||
});
|
||||
if !connected {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Available once the mixnet connects — or skip and claim later.",
|
||||
)
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
} else {
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
if !connected {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
|
||||
let main_label = if registered {
|
||||
"Open my wallet"
|
||||
} else {
|
||||
"Skip for now"
|
||||
};
|
||||
if w::big_action(ui, main_label, false).clicked() {
|
||||
return Some(wallet);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Recovery-phrase QR scan modal content.
|
||||
fn scan_modal_ui(&mut self, ui: &mut egui::Ui, _: &Modal, cb: &dyn PlatformCallbacks) {
|
||||
if let Some(content) = self.scan_modal.as_mut() {
|
||||
content.modal_ui(ui, cb, |result| match result {
|
||||
QrScanResult::Text(text) => {
|
||||
self.mnemonic_setup.mnemonic.import(&text);
|
||||
Modal::close();
|
||||
}
|
||||
QrScanResult::SeedQR(text) => {
|
||||
self.mnemonic_setup.mnemonic.import(&text);
|
||||
Modal::close();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
// Copyright 2026 The Goblin Developers
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Reusable Goblin design widgets: avatars, amounts, buttons, rows, chips.
|
||||
|
||||
use eframe::epaint::{CornerRadius, FontId, Stroke};
|
||||
use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, Vec2};
|
||||
|
||||
use crate::gui::theme::{self, fonts};
|
||||
|
||||
/// Currency mark for grin amounts.
|
||||
pub const TSU: &str = "ツ";
|
||||
|
||||
/// Format atomic grin units to a trimmed human string (no unit).
|
||||
pub fn amount_str(atomic: u64) -> String {
|
||||
grin_core::core::amount_to_hr_string(atomic, true)
|
||||
}
|
||||
|
||||
/// Draw a colored avatar puck with the contact initial.
|
||||
pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let (bg, ink) = theme::avatar_pair(hue);
|
||||
ui.painter().circle_filled(rect.center(), size / 2.0, bg);
|
||||
// First letter of the name — never the @ prefix or other decoration.
|
||||
let initial = name
|
||||
.chars()
|
||||
.find(|c| c.is_alphanumeric())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
initial,
|
||||
FontId::new(size * 0.42, fonts::bold()),
|
||||
ink,
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A custom-picture avatar: the texture drawn in a circle.
|
||||
pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let rounding = eframe::epaint::CornerRadius::same((size / 2.0) as u8);
|
||||
egui::Image::new(tex)
|
||||
.corner_radius(rounding)
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.paint_at(ui, rect);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Deterministic gradient avatar (a pubkey-seeded two-tone tile with the Grin
|
||||
/// mark on top) — the fallback for anonymous nostr users. `id` is the npub or
|
||||
/// hex pubkey; the image is a pure function of it, so the same key always draws
|
||||
/// the same avatar (see [`super::identicon`]). Cached per-pubkey by egui.
|
||||
pub fn gradient_avatar(ui: &mut Ui, id: &str, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let hex = super::identicon::to_hex_seed(id);
|
||||
// Rasterize at 2x for crispness; egui caches the texture by the `uri`, so the
|
||||
// SVG is generated/rasterized once per pubkey regardless of frames or size.
|
||||
let svg = super::identicon::gradient_avatar_svg(&hex, (size * 2.0) as u32, "");
|
||||
let uri = format!("bytes://gobavatar-{}-{}.svg", hex, size as u32);
|
||||
egui::Image::new(egui::ImageSource::Bytes {
|
||||
uri: uri.into(),
|
||||
bytes: svg.into_bytes().into(),
|
||||
})
|
||||
.corner_radius(CornerRadius::same((size / 2.0) as u8))
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.paint_at(ui, rect);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A named user's avatar: the same pubkey-seeded gradient background as
|
||||
/// [`gradient_avatar`], but with the person's initial painted on top (white with
|
||||
/// a faint dark shadow for legibility on any hue) instead of the Grin mark. `id`
|
||||
/// seeds the gradient; `name` supplies the letter.
|
||||
pub fn gradient_letter_avatar(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let hex = super::identicon::to_hex_seed(id);
|
||||
let svg = super::identicon::gradient_bg_svg(&hex, (size * 2.0) as u32);
|
||||
let uri = format!("bytes://gobavatarbg-{}-{}.svg", hex, size as u32);
|
||||
egui::Image::new(egui::ImageSource::Bytes {
|
||||
uri: uri.into(),
|
||||
bytes: svg.into_bytes().into(),
|
||||
})
|
||||
.corner_radius(CornerRadius::same((size / 2.0) as u8))
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.paint_at(ui, rect);
|
||||
// Initial — first alphanumeric of the name, never the @ prefix.
|
||||
let initial = name
|
||||
.chars()
|
||||
.find(|c| c.is_alphanumeric())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let font = FontId::new(size * 0.46, fonts::bold());
|
||||
let c = rect.center();
|
||||
ui.painter().text(
|
||||
c + Vec2::splat(size * 0.03),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
&initial,
|
||||
font.clone(),
|
||||
Color32::from_black_alpha(80),
|
||||
);
|
||||
ui.painter().text(
|
||||
c,
|
||||
egui::Align2::CENTER_CENTER,
|
||||
&initial,
|
||||
font,
|
||||
Color32::from_rgb(0xFA, 0xFA, 0xF7),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Picture avatar when a texture exists; otherwise the deterministic
|
||||
/// pubkey-seeded gradient: with the Grin mark for an anonymous key (display name
|
||||
/// is an `npub…`), or with the person's initial for a named contact/@handle. A
|
||||
/// flat lettered tile is the last resort when no pubkey is known. `id` is the
|
||||
/// npub/hex used to seed the gradient.
|
||||
pub fn avatar_any(
|
||||
ui: &mut Ui,
|
||||
name: &str,
|
||||
id: &str,
|
||||
size: f32,
|
||||
hue: usize,
|
||||
tex: Option<&egui::TextureHandle>,
|
||||
) -> Response {
|
||||
match tex {
|
||||
Some(t) => avatar_tex(ui, t, size),
|
||||
None if name.starts_with("npub") && !id.is_empty() => gradient_avatar(ui, id, size),
|
||||
None if !id.is_empty() => gradient_letter_avatar(ui, id, name, size),
|
||||
None => avatar(ui, name, size, hue),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a balance/amount: big bold number + smaller ツ mark, tight.
|
||||
/// Geist (sans) per the design; mono is reserved for kernel/block ids.
|
||||
pub fn amount_text(ui: &mut Ui, value: &str, size: f32) {
|
||||
let t = theme::tokens();
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
ui.label(
|
||||
RichText::new(value)
|
||||
.font(FontId::new(size, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
ui.add_space(1.0);
|
||||
ui.label(
|
||||
RichText::new(TSU)
|
||||
.font(FontId::new(size * 0.4, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Like [`amount_text`] but centered in the available width.
|
||||
pub fn amount_text_centered(ui: &mut Ui, value: &str, size: f32) {
|
||||
let t = theme::tokens();
|
||||
amount_text_centered_ink(ui, value, size, t.text, t.text_dim);
|
||||
}
|
||||
|
||||
/// Centered amount with explicit inks, for drawing on card surfaces.
|
||||
pub fn amount_text_centered_ink(
|
||||
ui: &mut Ui,
|
||||
value: &str,
|
||||
size: f32,
|
||||
num_ink: Color32,
|
||||
mark_ink: Color32,
|
||||
) {
|
||||
amount_text_centered_shifted(ui, value, size, num_ink, mark_ink, 0.0);
|
||||
}
|
||||
|
||||
/// Like [`amount_text_centered_ink`] but nudged horizontally by `dx` pixels — the
|
||||
/// hook for the "can't pay that" shake on the Pay screen.
|
||||
pub fn amount_text_centered_shifted(
|
||||
ui: &mut Ui,
|
||||
value: &str,
|
||||
size: f32,
|
||||
num_ink: Color32,
|
||||
mark_ink: Color32,
|
||||
dx: f32,
|
||||
) {
|
||||
let avail = ui.available_width();
|
||||
let measure = |ui: &Ui, sz: f32| -> f32 {
|
||||
let num =
|
||||
ui.painter()
|
||||
.layout_no_wrap(value.to_string(), FontId::new(sz, fonts::bold()), num_ink);
|
||||
let mark = ui.painter().layout_no_wrap(
|
||||
TSU.to_string(),
|
||||
FontId::new(sz * 0.4, fonts::medium()),
|
||||
mark_ink,
|
||||
);
|
||||
num.size().x + 1.0 + mark.size().x
|
||||
};
|
||||
// Shrink to fit: a long balance (e.g. 0.46520721ツ) must not run off the
|
||||
// edge. Glyph width is ~linear in font size, so scale down to the available
|
||||
// width with a small margin and a sane floor.
|
||||
let mut size = size;
|
||||
let total0 = measure(ui, size);
|
||||
if total0 > avail && total0 > 1.0 {
|
||||
size = (size * (avail / total0) * 0.97).clamp(14.0, size);
|
||||
}
|
||||
let total = measure(ui, size);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
ui.add_space(((ui.available_width() - total) / 2.0 + dx).max(0.0));
|
||||
ui.label(
|
||||
RichText::new(value)
|
||||
.font(FontId::new(size, fonts::bold()))
|
||||
.color(num_ink),
|
||||
);
|
||||
ui.add_space(1.0);
|
||||
ui.label(
|
||||
RichText::new(TSU)
|
||||
.font(FontId::new(size * 0.4, fonts::medium()))
|
||||
.color(mark_ink),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// An uppercase letterspaced kicker label.
|
||||
pub fn kicker(ui: &mut Ui, text: &str) {
|
||||
let t = theme::tokens();
|
||||
ui.label(
|
||||
RichText::new(text.to_uppercase())
|
||||
.font(fonts::kicker())
|
||||
.color(t.text_mute),
|
||||
);
|
||||
}
|
||||
|
||||
/// A Cash-App-style on/off switch. Yellow (brand accent) when on, neutral track
|
||||
/// when off. Returns the response — the caller flips the bound state on click.
|
||||
pub fn toggle(ui: &mut Ui, on: bool) -> Response {
|
||||
let t = theme::tokens();
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::new(46.0, 28.0), Sense::click());
|
||||
let track = if on { t.accent } else { t.surface2 };
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(14), track);
|
||||
let knob_r = 11.0;
|
||||
let knob_x = if on {
|
||||
rect.right() - knob_r - 3.0
|
||||
} else {
|
||||
rect.left() + knob_r + 3.0
|
||||
};
|
||||
let knob = if on {
|
||||
t.accent_ink
|
||||
} else {
|
||||
t.surface_text_mute
|
||||
};
|
||||
ui.painter()
|
||||
.circle_filled(egui::pos2(knob_x, rect.center().y), knob_r, knob);
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// A segmented control (e.g. `["Scan", "My Code"]`). Highlights `selected`;
|
||||
/// returns `Some(i)` when a different segment is tapped.
|
||||
pub fn segmented(ui: &mut Ui, labels: &[&str], selected: usize) -> Option<usize> {
|
||||
let t = theme::tokens();
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::hover());
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(22), t.surface2);
|
||||
let inner = rect.shrink(4.0);
|
||||
let seg_w = inner.width() / labels.len().max(1) as f32;
|
||||
let mut clicked = None;
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let seg = egui::Rect::from_min_size(
|
||||
inner.min + Vec2::new(i as f32 * seg_w, 0.0),
|
||||
Vec2::new(seg_w, inner.height()),
|
||||
);
|
||||
let resp = ui.interact(seg, ui.id().with(("seg", i)), Sense::click());
|
||||
let on = i == selected;
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(seg, CornerRadius::same(18), t.accent);
|
||||
}
|
||||
ui.painter().text(
|
||||
seg.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
*label,
|
||||
FontId::new(
|
||||
15.0,
|
||||
if on {
|
||||
fonts::semibold()
|
||||
} else {
|
||||
fonts::regular()
|
||||
},
|
||||
),
|
||||
if on { t.accent_ink } else { t.surface_text_dim },
|
||||
);
|
||||
if resp.clicked() && !on {
|
||||
clicked = Some(i);
|
||||
}
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Big primary/secondary action button (56px, radius 14).
|
||||
pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response {
|
||||
let t = theme::tokens();
|
||||
let desired = Vec2::new(ui.available_width(), 56.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
|
||||
let (fill, ink, stroke) = if secondary {
|
||||
(Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line))
|
||||
} else {
|
||||
(t.accent, t.accent_ink, Stroke::NONE)
|
||||
};
|
||||
let visual_fill = if resp.hovered() && !secondary {
|
||||
t.accent_dark
|
||||
} else {
|
||||
fill
|
||||
};
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(14),
|
||||
visual_fill,
|
||||
stroke,
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
label,
|
||||
FontId::new(17.0, fonts::semibold()),
|
||||
ink,
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Secondary big action drawn on a card surface: same shape as
|
||||
/// [`big_action`], but the label uses on-surface text so it stays readable
|
||||
/// on the yellow theme's dark cards.
|
||||
pub fn big_action_on_card(ui: &mut Ui, label: &str) -> Response {
|
||||
let t = theme::tokens();
|
||||
let desired = Vec2::new(ui.available_width(), 56.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(14),
|
||||
Color32::TRANSPARENT,
|
||||
Stroke::new(1.5, t.line),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
label,
|
||||
FontId::new(17.0, fonts::semibold()),
|
||||
t.surface_text,
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Like [`big_action_on_card`] with an explicit label ink (danger actions).
|
||||
pub fn big_action_on_card_ink(ui: &mut Ui, label: &str, ink: Color32) -> Response {
|
||||
let t = theme::tokens();
|
||||
let desired = Vec2::new(ui.available_width(), 44.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(14),
|
||||
Color32::TRANSPARENT,
|
||||
Stroke::new(1.5, t.line),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
label,
|
||||
FontId::new(15.0, fonts::semibold()),
|
||||
ink,
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A pill/chip; returns the click response. `active` paints it inverted.
|
||||
pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
|
||||
let t = theme::tokens();
|
||||
let galley = ui.painter().layout_no_wrap(
|
||||
label.to_string(),
|
||||
FontId::new(13.0, fonts::semibold()),
|
||||
if active { t.bg } else { t.surface_text },
|
||||
);
|
||||
let pad = Vec2::new(14.0, 8.0);
|
||||
let size = galley.size() + pad * 2.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let fill = if active { t.text } else { t.surface2 };
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(255),
|
||||
fill,
|
||||
Stroke::NONE,
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().galley(
|
||||
rect.center() - galley.size() / 2.0,
|
||||
galley,
|
||||
if active { t.bg } else { t.surface_text },
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// An outline pill chip (transparent fill, line border) per the design's
|
||||
/// amount quick-select row.
|
||||
pub fn chip_outline(ui: &mut Ui, label: &str) -> Response {
|
||||
let t = theme::tokens();
|
||||
let galley = ui.painter().layout_no_wrap(
|
||||
label.to_string(),
|
||||
FontId::new(13.0, fonts::semibold()),
|
||||
t.text,
|
||||
);
|
||||
let pad = Vec2::new(14.0, 8.0);
|
||||
let size = galley.size() + pad * 2.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(255),
|
||||
Color32::TRANSPARENT,
|
||||
Stroke::new(1.0, t.line),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter()
|
||||
.galley(rect.center() - galley.size() / 2.0, galley, t.text);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Paint a QR code for `text` with the goblin mark centered, per the
|
||||
/// design's receive card. Always dark modules on a white plate, whatever the
|
||||
/// theme: inverted (light-on-dark) codes fail to decode in a number of
|
||||
/// scanner apps. Encoding a short URI is microseconds, so this is done
|
||||
/// synchronously each frame; modules are plain painter rects.
|
||||
pub fn qr_code(ui: &mut Ui, text: &str, size: f32) {
|
||||
let plate = Color32::WHITE;
|
||||
let ink = Color32::from_rgb(0x0E, 0x0E, 0x0C);
|
||||
// High error correction tolerates the center mark covering modules.
|
||||
let Ok(qr) = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::High) else {
|
||||
return;
|
||||
};
|
||||
let pad = (size * 0.05).max(8.0);
|
||||
let (outer, _) = ui.allocate_exact_size(Vec2::splat(size + pad * 2.0), Sense::hover());
|
||||
ui.painter()
|
||||
.rect_filled(outer, CornerRadius::same(16), plate);
|
||||
let rect = outer.shrink(pad);
|
||||
let n = qr.size();
|
||||
let cell = size / n as f32;
|
||||
// Full cells with no inter-module gap: at receive-card density (~4.5px
|
||||
// cells) even a 0.5px gap fragments the finder patterns and scanners
|
||||
// fail to detect the code at all (probed with rqrr). Corner rounding
|
||||
// only when cells are big enough that the notching can't matter.
|
||||
let radius = if cell >= 6.0 { (cell * 0.3) as u8 } else { 0 };
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
if qr.get_module(x, y) {
|
||||
let min = rect.min + Vec2::new(x as f32 * cell, y as f32 * cell);
|
||||
ui.painter().rect_filled(
|
||||
egui::Rect::from_min_size(min, Vec2::splat(cell)),
|
||||
CornerRadius::same(radius),
|
||||
ink,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Goblin mark on a yellow backing square in the center, same 19% footprint
|
||||
// the white version was tuned to (at 26%, zbar-class scanners fail on the
|
||||
// glyph; 19% passes everything probed). Yellow's luminance reads as "light"
|
||||
// to a scanner just like white, so the obscured center is recovered by the
|
||||
// High ECC exactly as before — only the colour changes.
|
||||
let t = theme::tokens();
|
||||
let backing = size * 0.19;
|
||||
let b_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing));
|
||||
ui.painter()
|
||||
.rect_filled(b_rect, CornerRadius::same((backing * 0.18) as u8), t.accent);
|
||||
let m_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing * 0.72));
|
||||
egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg"))
|
||||
.tint(t.accent_ink)
|
||||
.fit_to_exact_size(m_rect.size())
|
||||
.paint_at(ui, m_rect);
|
||||
}
|
||||
|
||||
/// A filled input well for a text field sitting on a card, so the field
|
||||
/// reads as a field: frameless edits on the card fill are invisible.
|
||||
pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
|
||||
let t = theme::tokens();
|
||||
egui::Frame {
|
||||
fill: t.surface2,
|
||||
stroke: Stroke::new(1.0, t.line),
|
||||
corner_radius: CornerRadius::same(10),
|
||||
inner_margin: egui::Margin::symmetric(12, 10),
|
||||
..Default::default()
|
||||
}
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
content(ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// A balance hero block: kicker, big number + ツ, optional fiat line.
|
||||
pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) {
|
||||
let t = theme::tokens();
|
||||
// Centered to match the Pay amount and the empty-state below it.
|
||||
ui.vertical_centered(|ui| kicker(ui, "Balance"));
|
||||
ui.add_space(6.0);
|
||||
amount_text_centered(ui, &amount_str(atomic), size);
|
||||
if let Some(fiat) = fiat {
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(fiat)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// An activity row: avatar, title, subtitle, signed amount.
|
||||
/// Returns the row click response.
|
||||
pub fn activity_row(
|
||||
ui: &mut Ui,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
hue: usize,
|
||||
id: &str,
|
||||
amount: &str,
|
||||
incoming: bool,
|
||||
system: bool,
|
||||
tex: Option<&egui::TextureHandle>,
|
||||
) -> Response {
|
||||
let t = theme::tokens();
|
||||
let row_h = 60.0;
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click());
|
||||
let mut content = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(rect.shrink2(Vec2::new(0.0, 8.0)))
|
||||
.layout(Layout::left_to_right(Align::Center)),
|
||||
);
|
||||
content.horizontal(|ui| {
|
||||
if system {
|
||||
let (r, _) = ui.allocate_exact_size(Vec2::splat(40.0), Sense::hover());
|
||||
ui.painter().rect(
|
||||
r,
|
||||
CornerRadius::same(10),
|
||||
t.surface2,
|
||||
Stroke::NONE,
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
crate::gui::icons::CUBE,
|
||||
FontId::new(20.0, fonts::regular()),
|
||||
t.text,
|
||||
);
|
||||
} else {
|
||||
avatar_any(ui, title, id, 40.0, hue, tex);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(title)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.text),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
// Single-line, truncated: keeps the fixed-height row tidy even when
|
||||
// the subtitle is a long value (e.g. a full npub in the picker).
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(subtitle)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
});
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(amount)
|
||||
.font(FontId::new(15.0, fonts::mono_semibold()))
|
||||
.color(if incoming { t.pos } else { t.text }),
|
||||
);
|
||||
});
|
||||
});
|
||||
// Divider.
|
||||
let line_y = rect.bottom();
|
||||
ui.painter()
|
||||
.hline(rect.left()..=rect.right(), line_y, Stroke::new(1.0, t.line));
|
||||
resp
|
||||
}
|
||||
|
||||
/// Section header used above grouped lists.
|
||||
pub fn section_header(ui: &mut Ui, text: &str) {
|
||||
ui.add_space(8.0);
|
||||
kicker(ui, text);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// Draw a rounded surface card and run a closure inside it.
|
||||
pub fn card<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let t = theme::tokens();
|
||||
egui::Frame::new()
|
||||
.fill(t.surface)
|
||||
.stroke(Stroke::new(1.0, t.line))
|
||||
.corner_radius(CornerRadius::same(18))
|
||||
.inner_margin(16.0)
|
||||
.show(ui, add_contents)
|
||||
.inner
|
||||
}
|
||||
|
||||
/// A bordered rect helper for non-interactive value rows.
|
||||
pub fn info_row(ui: &mut Ui, label: &str, value: &str) {
|
||||
let t = theme::tokens();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
// Truncate so a long value (e.g. "Encrypted nostr DM over Nym") never
|
||||
// runs past the edge or collides with the label on a narrow screen.
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(value)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.text),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.painter().hline(
|
||||
ui.min_rect().left()..=ui.min_rect().right(),
|
||||
ui.cursor().top(),
|
||||
Stroke::new(1.0, t.line),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
/// Draw a centered Send / Receive split. Returns (send, receive) clicks.
|
||||
pub fn send_receive(ui: &mut Ui) -> (bool, bool) {
|
||||
let t = theme::tokens();
|
||||
let mut send = false;
|
||||
let mut receive = false;
|
||||
let h = 60.0;
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - 10.0) / 2.0;
|
||||
let (rs, resp_s) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click());
|
||||
ui.painter().rect(
|
||||
rs,
|
||||
CornerRadius::same(14),
|
||||
if resp_s.hovered() {
|
||||
t.accent_dark
|
||||
} else {
|
||||
t.accent
|
||||
},
|
||||
Stroke::NONE,
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(
|
||||
rs.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!("{} Send", crate::gui::icons::ARROW_UP),
|
||||
FontId::new(16.0, fonts::semibold()),
|
||||
t.accent_ink,
|
||||
);
|
||||
send = resp_s.clicked();
|
||||
ui.add_space(10.0);
|
||||
let (rr, resp_r) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click());
|
||||
let r_fill = if resp_r.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.surface2
|
||||
};
|
||||
ui.painter().rect(
|
||||
rr,
|
||||
CornerRadius::same(14),
|
||||
r_fill,
|
||||
Stroke::NONE,
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(
|
||||
rr.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!("{} Receive", crate::gui::icons::ARROW_DOWN),
|
||||
FontId::new(16.0, fonts::semibold()),
|
||||
theme::ink_for(r_fill),
|
||||
);
|
||||
receive = resp_r.clicked();
|
||||
});
|
||||
(send, receive)
|
||||
}
|
||||
|
||||
/// A simple numeric keypad. Mutates `amount` string. Returns true if changed.
|
||||
pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
|
||||
let t = theme::tokens();
|
||||
let mut changed = false;
|
||||
let keys = [
|
||||
["1", "2", "3"],
|
||||
["4", "5", "6"],
|
||||
["7", "8", "9"],
|
||||
[".", "0", "<"],
|
||||
];
|
||||
let key_h = 58.0;
|
||||
let gap = 14.0;
|
||||
// Center a fixed-width pad so the three columns line up directly under
|
||||
// the centered amount above, on any width. Wider than before to give the
|
||||
// columns more breathing room (Cash App-style).
|
||||
let pad_w = ui.available_width().min(332.0);
|
||||
let key_w = (pad_w - 2.0 * gap) / 3.0;
|
||||
let side = ((ui.available_width() - pad_w) / 2.0).max(0.0);
|
||||
// Spread the four rows toward the bottom when there's room (the Pay tab,
|
||||
// which otherwise leaves a big empty gap), staying compact on dense
|
||||
// screens (the send flow). Reserve space below for the action buttons and
|
||||
// the floating tab bar. Clamped so it never stretches absurdly or overflows.
|
||||
let reserve_below = 170.0;
|
||||
let avail = (ui.available_height() - reserve_below).max(0.0);
|
||||
let row_gap = ((avail - key_h * 4.0) / 3.0).clamp(6.0, 30.0);
|
||||
for (ri, row) in keys.iter().enumerate() {
|
||||
if ri > 0 {
|
||||
ui.add_space(row_gap);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(side);
|
||||
for (i, &k) in row.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.add_space(gap);
|
||||
}
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::new(key_w, key_h), Sense::click());
|
||||
let label = if k == "<" {
|
||||
crate::gui::icons::BACKSPACE.to_string()
|
||||
} else {
|
||||
k.to_string()
|
||||
};
|
||||
let col = if resp.hovered() { t.accent } else { t.text };
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
label,
|
||||
FontId::new(30.0, fonts::medium()),
|
||||
col,
|
||||
);
|
||||
if resp.clicked() {
|
||||
apply_key(amount, k);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply a numpad key to the amount string with validation.
|
||||
/// Apply typed keyboard events (digits, '.', backspace) to an amount string,
|
||||
/// for desktop where the on-screen numpad is hidden.
|
||||
pub fn amount_typed_input(ui: &Ui, amount: &mut String) {
|
||||
ui.input(|i| {
|
||||
for ev in &i.events {
|
||||
if let egui::Event::Text(txt) = ev {
|
||||
for ch in txt.chars() {
|
||||
if ch.is_ascii_digit() {
|
||||
apply_key(amount, &ch.to_string());
|
||||
} else if ch == '.' {
|
||||
apply_key(amount, ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let egui::Event::Key {
|
||||
key: egui::Key::Backspace,
|
||||
pressed: true,
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
apply_key(amount, "<");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn apply_key(amount: &mut String, key: &str) {
|
||||
match key {
|
||||
"<" => {
|
||||
amount.pop();
|
||||
}
|
||||
"." => {
|
||||
if !amount.contains('.') {
|
||||
if amount.is_empty() {
|
||||
amount.push('0');
|
||||
}
|
||||
amount.push('.');
|
||||
}
|
||||
}
|
||||
d => {
|
||||
// Limit to 9 decimals (grin precision).
|
||||
if let Some(dot) = amount.find('.') {
|
||||
if amount.len() - dot - 1 >= 9 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Avoid leading zeros like "00".
|
||||
if amount == "0" {
|
||||
amount.clear();
|
||||
}
|
||||
amount.push_str(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a full-rect background fill on the current panel.
|
||||
pub fn fill_bg(ui: &Ui, color: Color32) {
|
||||
let rect = ui.ctx().screen_rect();
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, color);
|
||||
}
|
||||
|
||||
/// Center a fixed-width column for narrow content on wide screens.
|
||||
/// Hands the child the full remaining height: wrapping in `horizontal()`
|
||||
/// would start the row a single line tall, so a `ScrollArea` inside would
|
||||
/// clip everything below the first widget.
|
||||
pub fn centered_column<R>(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
// Keep a small side gutter so content sits close to the screen edges on
|
||||
// phones (where `width` exceeds the available width) without running flush.
|
||||
const MIN_SIDE_PAD: f32 = 8.0;
|
||||
let avail = ui.available_width();
|
||||
let w = width.min(avail - MIN_SIDE_PAD * 2.0).max(0.0);
|
||||
let margin = ((avail - w) / 2.0).max(MIN_SIDE_PAD);
|
||||
let mut rect = ui.available_rect_before_wrap();
|
||||
rect.min.x += margin;
|
||||
rect.max.x = rect.min.x + w;
|
||||
let mut child = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(rect)
|
||||
.layout(Layout::top_down(Align::Min)),
|
||||
);
|
||||
let result = add(&mut child);
|
||||
ui.allocate_rect(child.min_rect(), Sense::hover());
|
||||
result
|
||||
}
|
||||
|
||||
/// Hold-to-send button: fills over `hold_secs`; returns true once on completion.
|
||||
pub struct HoldToSend {
|
||||
progress: f32,
|
||||
}
|
||||
|
||||
impl Default for HoldToSend {
|
||||
fn default() -> Self {
|
||||
Self { progress: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl HoldToSend {
|
||||
pub fn ui(&mut self, ui: &mut Ui, label: &str) -> bool {
|
||||
let t = theme::tokens();
|
||||
let (rect, resp) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), 56.0),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
// Background.
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(14),
|
||||
t.surface2,
|
||||
Stroke::NONE,
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let held = resp.is_pointer_button_down_on() || resp.dragged();
|
||||
let dt = ui.input(|i| i.stable_dt).min(0.1);
|
||||
if held {
|
||||
self.progress = (self.progress + dt / 0.7).min(1.0);
|
||||
ui.ctx().request_repaint();
|
||||
} else {
|
||||
self.progress = (self.progress - dt / 0.3).max(0.0);
|
||||
if self.progress > 0.0 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
// Progress fill.
|
||||
if self.progress > 0.0 {
|
||||
let mut fill_rect = rect;
|
||||
fill_rect.set_width(rect.width() * self.progress);
|
||||
ui.painter().rect(
|
||||
fill_rect,
|
||||
CornerRadius::same(14),
|
||||
t.accent,
|
||||
Stroke::NONE,
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
label,
|
||||
FontId::new(17.0, fonts::semibold()),
|
||||
if self.progress > 0.5 {
|
||||
t.accent_ink
|
||||
} else {
|
||||
theme::ink_for(t.surface2)
|
||||
},
|
||||
);
|
||||
if self.progress >= 1.0 {
|
||||
self.progress = 0.0;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten a long key/address for display (8…6).
|
||||
pub fn short_key(key: &str) -> String {
|
||||
if key.len() <= 16 {
|
||||
return key.to_string();
|
||||
}
|
||||
format!("{}…{}", &key[..8], &key[key.len() - 6..])
|
||||
}
|
||||
@@ -52,6 +52,12 @@ pub struct TextEdit {
|
||||
numeric: bool,
|
||||
/// Flag to not show soft keyboard.
|
||||
no_soft_keyboard: bool,
|
||||
/// Optional placeholder shown when empty.
|
||||
hint: Option<String>,
|
||||
/// Optional text color override (defaults to the theme text color).
|
||||
text_color: Option<egui::Color32>,
|
||||
/// Use the body text style instead of the default heading.
|
||||
body_font: bool,
|
||||
}
|
||||
|
||||
impl TextEdit {
|
||||
@@ -72,7 +78,16 @@ impl TextEdit {
|
||||
scan_pressed: false,
|
||||
enter_pressed: false,
|
||||
numeric: false,
|
||||
no_soft_keyboard: is_android(),
|
||||
// Goblin uses each platform's NATIVE input everywhere: the Android
|
||||
// soft keyboard via JNI on Android, the physical keyboard on desktop.
|
||||
// Upstream Grim only suppresses its own on-screen keyboard on Android
|
||||
// (`is_android()`) and pops it on desktop — which looked out of place
|
||||
// in Goblin's wallet flows and competed with physical typing. Suppress
|
||||
// it on every platform; native text entry still works.
|
||||
no_soft_keyboard: true,
|
||||
hint: None,
|
||||
text_color: None,
|
||||
body_font: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,14 +187,21 @@ impl TextEdit {
|
||||
|
||||
// Show text edit.
|
||||
let text_edit_resp = egui::TextEdit::singleline(input)
|
||||
.text_color(if self.enabled {
|
||||
Colors::text(false)
|
||||
} else {
|
||||
.hint_text(self.hint.clone().unwrap_or_default())
|
||||
.text_color(if !self.enabled {
|
||||
Colors::inactive_text()
|
||||
} else if let Some(c) = self.text_color {
|
||||
c
|
||||
} else {
|
||||
Colors::text(false)
|
||||
})
|
||||
.interactive(self.enabled)
|
||||
.id(self.id)
|
||||
.font(TextStyle::Heading)
|
||||
.font(if self.body_font {
|
||||
TextStyle::Body
|
||||
} else {
|
||||
TextStyle::Heading
|
||||
})
|
||||
.min_size(edit_rect.size())
|
||||
.margin(if View::is_desktop() {
|
||||
egui::Margin::symmetric(4, 2)
|
||||
@@ -411,6 +433,24 @@ impl TextEdit {
|
||||
self.no_soft_keyboard = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set placeholder text shown when the field is empty.
|
||||
pub fn hint_text(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the text color (e.g. to match an on-card theme token).
|
||||
pub fn text_color(mut self, color: egui::Color32) -> Self {
|
||||
self.text_color = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Render with the body text style instead of the default heading.
|
||||
pub fn body(mut self) -> Self {
|
||||
self.body_font = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if current system is Android.
|
||||
|
||||
@@ -26,6 +26,7 @@ pub use modal::*;
|
||||
mod content;
|
||||
pub use content::*;
|
||||
|
||||
pub mod goblin;
|
||||
pub mod network;
|
||||
pub mod settings;
|
||||
pub mod wallets;
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::GLOBE_SIMPLE;
|
||||
use crate::gui::icons::{DATABASE, FADERS, GLOBE_SIMPLE, POWER};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::views::View;
|
||||
use crate::gui::views::network::NetworkContent;
|
||||
use crate::gui::views::settings::{InterfaceSettingsContent, NetworkSettingsContent};
|
||||
use crate::gui::views::types::ContentContainer;
|
||||
use crate::gui::views::{Content, View};
|
||||
use crate::node::Node;
|
||||
|
||||
/// Application settings content.
|
||||
pub struct SettingsContent {
|
||||
@@ -64,6 +66,51 @@ impl SettingsContent {
|
||||
self.network_settings.ui(ui, cb);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Integrated node — relocated here from the wallet-list chip so the
|
||||
// list stays uncluttered. Quick status + enable/autorun, plus a button
|
||||
// into the full node panel (stats, mining, tuning, recovery).
|
||||
View::horizontal_line(ui, Colors::stroke());
|
||||
ui.add_space(6.0);
|
||||
View::sub_title(ui, format!("{} {}", DATABASE, t!("network.node")));
|
||||
View::horizontal_line(ui, Colors::stroke());
|
||||
ui.add_space(8.0);
|
||||
|
||||
let running = Node::is_running();
|
||||
let (status_color, status_text) = if !running {
|
||||
(Colors::gray(), "Disabled")
|
||||
} else if Node::not_syncing() {
|
||||
(Colors::pos(), "Running · synced")
|
||||
} else {
|
||||
(Colors::gold(), "Running · syncing…")
|
||||
};
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(status_text)
|
||||
.size(15.0)
|
||||
.color(status_color),
|
||||
);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
if !running {
|
||||
View::action_button(
|
||||
ui,
|
||||
format!("{} {}", POWER, t!("network.enable_node")),
|
||||
|| {
|
||||
Node::start();
|
||||
},
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
NetworkContent::autorun_node_ui(ui);
|
||||
ui.add_space(8.0);
|
||||
View::action_button(ui, format!("{} {}", FADERS, t!("network.settings")), || {
|
||||
if !Content::is_network_panel_open() {
|
||||
Content::toggle_network_panel();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Do not show Tor settings on Android.
|
||||
// let os = OperatingSystem::from_target_os();
|
||||
// let show_tor = os != OperatingSystem::Android;
|
||||
|
||||
@@ -20,6 +20,3 @@ pub use interface::*;
|
||||
|
||||
mod network;
|
||||
pub use network::*;
|
||||
|
||||
mod tor;
|
||||
pub use tor::*;
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
// Copyright 2025 The Grim Developers
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use eframe::epaint::RectShape;
|
||||
use egui::scroll_area::ScrollBarVisibility;
|
||||
use egui::{Align, CursorIcon, Id, Layout, RichText, ScrollArea, Sense, StrokeKind, UiBuilder};
|
||||
use std::fs;
|
||||
use url::Url;
|
||||
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::{CLIPBOARD_TEXT, CLOUD_CHECK, NOTCHES, PENCIL, SCAN, TERMINAL};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::views::types::{ContentContainer, ModalPosition};
|
||||
use crate::gui::views::{
|
||||
CameraScanContent, FilePickContent, FilePickContentType, Modal, TextEdit, View,
|
||||
};
|
||||
use crate::tor::{TorBridge, TorConfig, TorProxy};
|
||||
|
||||
/// Transport settings content.
|
||||
pub struct TorSettingsContent {
|
||||
/// Flag to check if settings were changed.
|
||||
pub settings_changed: bool,
|
||||
|
||||
/// Proxy URL input value for [`Modal`].
|
||||
proxy_url_edit: String,
|
||||
/// Flag to check if entered proxy address was correct.
|
||||
proxy_url_error: bool,
|
||||
|
||||
/// Tor bridge binary path value for [`Modal`].
|
||||
bridge_bin_path_edit: String,
|
||||
/// Button to pick binary file for bridge.
|
||||
bridge_bin_pick_file: FilePickContent,
|
||||
|
||||
/// Tor bridge connection line value for [`Modal`].
|
||||
bridge_conn_line_edit: String,
|
||||
/// Bridge line QR code scanner [`Modal`] content.
|
||||
bridge_qr_scan_content: Option<CameraScanContent>,
|
||||
}
|
||||
|
||||
/// Identifier for proxy URL edit [`Modal`].
|
||||
const PROXY_URL_EDIT_MODAL: &'static str = "tor_proxy_edit_modal";
|
||||
/// Identifier for bridge binary path input [`Modal`].
|
||||
const BRIDGE_BIN_EDIT_MODAL: &'static str = "bridge_bin_edit_modal";
|
||||
/// Identifier for bridge connection line input [`Modal`].
|
||||
const BRIDGE_CONN_LINE_EDIT_MODAL: &'static str = "bridge_conn_line_edit_modal";
|
||||
/// Identifier for [`Modal`] to scan bridge line from QR code.
|
||||
const SCAN_BRIDGE_CONN_LINE_MODAL: &'static str = "scan_bridge_conn_line_modal";
|
||||
|
||||
impl Default for TorSettingsContent {
|
||||
fn default() -> Self {
|
||||
// Setup Tor bridge binary path edit text.
|
||||
let bridge = TorConfig::get_bridge();
|
||||
let (bin_path, conn_line) = if let Some(b) = bridge {
|
||||
(b.binary_path(), b.connection_line())
|
||||
} else {
|
||||
("".to_string(), "".to_string())
|
||||
};
|
||||
Self {
|
||||
settings_changed: false,
|
||||
proxy_url_edit: "".to_string(),
|
||||
proxy_url_error: false,
|
||||
bridge_bin_path_edit: bin_path,
|
||||
bridge_bin_pick_file: FilePickContent::new(FilePickContentType::ItemButton(
|
||||
View::item_rounding(0, 1, true),
|
||||
))
|
||||
.no_parse(),
|
||||
bridge_conn_line_edit: conn_line,
|
||||
bridge_qr_scan_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentContainer for TorSettingsContent {
|
||||
fn modal_ids(&self) -> Vec<&'static str> {
|
||||
vec![
|
||||
PROXY_URL_EDIT_MODAL,
|
||||
BRIDGE_BIN_EDIT_MODAL,
|
||||
BRIDGE_CONN_LINE_EDIT_MODAL,
|
||||
SCAN_BRIDGE_CONN_LINE_MODAL,
|
||||
]
|
||||
}
|
||||
|
||||
fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
|
||||
match modal.id {
|
||||
PROXY_URL_EDIT_MODAL => self.proxy_modal_ui(ui, cb),
|
||||
BRIDGE_BIN_EDIT_MODAL => self.bridge_bin_edit_modal_ui(ui, cb),
|
||||
BRIDGE_CONN_LINE_EDIT_MODAL => self.bridge_conn_line_edit_modal_ui(ui, cb),
|
||||
SCAN_BRIDGE_CONN_LINE_MODAL => {
|
||||
if let Some(content) = self.bridge_qr_scan_content.as_mut() {
|
||||
let mut close = false;
|
||||
content.modal_ui(ui, cb, |res| {
|
||||
// Save connection line after scanning.
|
||||
let line = res.text();
|
||||
let bridge = TorConfig::get_bridge().unwrap();
|
||||
if bridge.connection_line() != line {
|
||||
TorBridge::save_bridge_conn_line(&bridge, line);
|
||||
self.settings_changed = true;
|
||||
}
|
||||
close = true;
|
||||
});
|
||||
if close {
|
||||
self.bridge_qr_scan_content = None;
|
||||
cb.stop_camera();
|
||||
Modal::close();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
ui.label(
|
||||
RichText::new(format!("{}:", t!("wallets.conn_method")))
|
||||
.size(17.0)
|
||||
.color(Colors::inactive_text()),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
|
||||
let mut proxy = TorConfig::get_proxy();
|
||||
let current_proxy = proxy.clone();
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered(|ui| {
|
||||
let name = t!("network_settings.default");
|
||||
View::radio_value(ui, &mut proxy, None, name);
|
||||
});
|
||||
columns[1].vertical_centered(|ui| {
|
||||
let name = t!("app_settings.proxy");
|
||||
let val = current_proxy
|
||||
.clone()
|
||||
.unwrap_or(TorProxy::SOCKS5(TorProxy::DEFAULT_SOCKS5_URL.to_string()));
|
||||
View::radio_value(ui, &mut proxy, Some(val), name);
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
View::horizontal_line(ui, Colors::item_stroke());
|
||||
ui.add_space(6.0);
|
||||
|
||||
if let Some(p) = proxy.as_mut() {
|
||||
ui.label(
|
||||
RichText::new(format!("{}:", t!("app_settings.proxy")))
|
||||
.size(17.0)
|
||||
.color(Colors::inactive_text()),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered(|ui| {
|
||||
let value = TorConfig::get_socks5_proxy();
|
||||
View::radio_value(ui, p, value, "SOCKS5".to_string());
|
||||
});
|
||||
columns[1].vertical_centered(|ui| {
|
||||
let value = TorConfig::get_http_proxy();
|
||||
View::radio_value(ui, p, value, "HTTP".to_string());
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
// Show proxy settings.
|
||||
self.proxy_item_ui(p.url(), ui);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
// Check if proxy type was changed to save.
|
||||
if current_proxy != proxy {
|
||||
TorConfig::save_proxy(proxy.clone());
|
||||
self.settings_changed = true;
|
||||
}
|
||||
if proxy.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let bridge = TorConfig::get_bridge();
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("transport.bridges_desc"))
|
||||
.size(17.0)
|
||||
.color(Colors::inactive_text()),
|
||||
);
|
||||
|
||||
// Draw checkbox to enable/disable bridges.
|
||||
View::checkbox(ui, bridge.is_some(), t!("transport.bridges"), || {
|
||||
let value = if bridge.is_some() {
|
||||
None
|
||||
} else {
|
||||
let default_bridge = TorConfig::get_webtunnel();
|
||||
self.bridge_bin_path_edit = default_bridge.binary_path();
|
||||
self.bridge_conn_line_edit = default_bridge.connection_line();
|
||||
Some(default_bridge)
|
||||
};
|
||||
TorConfig::save_bridge(value);
|
||||
self.settings_changed = true;
|
||||
});
|
||||
});
|
||||
|
||||
if bridge.is_some() {
|
||||
ui.add_space(6.0);
|
||||
// Show bridge selection for desktop.
|
||||
if View::is_desktop() {
|
||||
let current_bridge = bridge.unwrap();
|
||||
let mut bridge = current_bridge.clone();
|
||||
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered(|ui| {
|
||||
// Show Webtunnel bridge selector.
|
||||
let webtunnel = TorConfig::get_webtunnel();
|
||||
let name = webtunnel.protocol_name().to_uppercase();
|
||||
View::radio_value(ui, &mut bridge, webtunnel, name);
|
||||
});
|
||||
columns[1].vertical_centered(|ui| {
|
||||
// Show Obfs4 bridge selector.
|
||||
let obfs4 = TorConfig::get_obfs4();
|
||||
let name = obfs4.protocol_name().to_uppercase();
|
||||
View::radio_value(ui, &mut bridge, obfs4, name);
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
// Show Snowflake bridge selector.
|
||||
let snowflake = TorConfig::get_snowflake();
|
||||
let name = snowflake.protocol_name().to_uppercase();
|
||||
View::radio_value(ui, &mut bridge, snowflake, name);
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Check if bridge type was changed to save.
|
||||
if current_bridge != bridge {
|
||||
TorConfig::save_bridge(Some(bridge.clone()));
|
||||
self.bridge_bin_path_edit = bridge.binary_path();
|
||||
self.bridge_conn_line_edit = bridge.connection_line();
|
||||
self.settings_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(br) = TorConfig::get_bridge().as_ref() {
|
||||
// Show bridge connection line setup.
|
||||
self.conn_line_ui(ui, br, cb);
|
||||
// Show bridge binary setup for desktop.
|
||||
if View::is_desktop() {
|
||||
self.bridge_bin_ui(ui, br, cb);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TorSettingsContent {
|
||||
/// Draw proxy edit modal content.
|
||||
fn proxy_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
let on_save = |c: &mut TorSettingsContent| {
|
||||
let http = "http://";
|
||||
let socks = "socks5://";
|
||||
let url = c.proxy_url_edit.trim().to_string();
|
||||
c.proxy_url_error = Url::parse(url.as_str()).is_err();
|
||||
if !c.proxy_url_error {
|
||||
let proxy = TorConfig::get_proxy().unwrap();
|
||||
if url.contains(socks) {
|
||||
TorConfig::save_proxy(Some(TorProxy::SOCKS5(url)));
|
||||
} else if url.contains(http) {
|
||||
TorConfig::save_proxy(Some(TorProxy::HTTP(url)));
|
||||
} else {
|
||||
match proxy {
|
||||
TorProxy::SOCKS5(_) => {
|
||||
TorConfig::save_proxy(Some(TorProxy::SOCKS5(url)));
|
||||
}
|
||||
TorProxy::HTTP(_) => {
|
||||
TorConfig::save_proxy(Some(TorProxy::HTTP(url)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Modal::close();
|
||||
}
|
||||
};
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
let label = format!("{}:", t!("enter_url"));
|
||||
ui.label(RichText::new(label).size(17.0).color(Colors::gray()));
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Draw proxy URL text edit.
|
||||
let mut edit =
|
||||
TextEdit::new(Id::from("proxy_url_edit").with(PROXY_URL_EDIT_MODAL)).paste();
|
||||
edit.ui(ui, &mut self.proxy_url_edit, cb);
|
||||
if edit.enter_pressed {
|
||||
on_save(self);
|
||||
}
|
||||
|
||||
// Show error when specified address is incorrect.
|
||||
if self.proxy_url_error {
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
RichText::new(t!("wallets.invalid_url"))
|
||||
.size(16.0)
|
||||
.color(Colors::red()),
|
||||
);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
|
||||
// Show modal buttons.
|
||||
ui.scope(|ui| {
|
||||
// Setup spacing between buttons.
|
||||
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
|
||||
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered_justified(|ui| {
|
||||
View::button(
|
||||
ui,
|
||||
t!("modal.cancel"),
|
||||
Colors::white_or_black(false),
|
||||
|| {
|
||||
Modal::close();
|
||||
},
|
||||
);
|
||||
});
|
||||
columns[1].vertical_centered_justified(|ui| {
|
||||
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
|
||||
on_save(self);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw proxy item content.
|
||||
fn proxy_item_ui(&mut self, url: String, ui: &mut egui::Ui) {
|
||||
// Setup layout size.
|
||||
let mut rect = ui.available_rect_before_wrap();
|
||||
rect.set_height(56.0);
|
||||
|
||||
// Draw round background.
|
||||
let bg_rect = rect.clone();
|
||||
let item_rounding = View::item_rounding(0, 1, false);
|
||||
ui.painter().rect(
|
||||
bg_rect,
|
||||
item_rounding,
|
||||
Colors::fill(),
|
||||
View::item_stroke(),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
|
||||
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| {
|
||||
View::item_button(ui, View::item_rounding(0, 1, true), PENCIL, None, || {
|
||||
self.proxy_url_edit = url.clone();
|
||||
// Show proxy URL edit modal.
|
||||
Modal::new(PROXY_URL_EDIT_MODAL)
|
||||
.position(ModalPosition::CenterTop)
|
||||
.title(t!("app_settings.proxy"))
|
||||
.show();
|
||||
});
|
||||
let layout_size = ui.available_size();
|
||||
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(4.0);
|
||||
View::ellipsize_text(ui, url, 18.0, Colors::title(false));
|
||||
ui.add_space(1.0);
|
||||
|
||||
let value = format!("{} {}", CLOUD_CHECK, t!("network_settings.enabled"));
|
||||
ui.label(RichText::new(value).size(15.0).color(Colors::gray()));
|
||||
ui.add_space(3.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw bridge connection line setup content.
|
||||
fn conn_line_ui(&mut self, ui: &mut egui::Ui, bridge: &TorBridge, cb: &dyn PlatformCallbacks) {
|
||||
// Draw round background.
|
||||
let mut rect = ui.available_rect_before_wrap();
|
||||
rect.set_height(56.0);
|
||||
let r = if View::is_desktop() {
|
||||
View::item_rounding(0, 2, false)
|
||||
} else {
|
||||
View::item_rounding(0, 1, false)
|
||||
};
|
||||
let bg = Colors::fill_lite();
|
||||
let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside);
|
||||
let bg_idx = ui.painter().add(bg_shape.clone());
|
||||
|
||||
let res = ui
|
||||
.scope_builder(
|
||||
UiBuilder::new()
|
||||
.sense(Sense::click())
|
||||
.layout(Layout::right_to_left(Align::Center))
|
||||
.max_rect(rect),
|
||||
|ui| {
|
||||
View::item_button(ui, View::item_rounding(0, 1, true), SCAN, None, || {
|
||||
self.show_qr_scan_bridge_modal(cb);
|
||||
});
|
||||
let layout_size = ui.available_size();
|
||||
ui.allocate_ui_with_layout(
|
||||
layout_size,
|
||||
Layout::left_to_right(Align::Center),
|
||||
|ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(4.0);
|
||||
let line_text = bridge.connection_line();
|
||||
View::ellipsize_text(ui, line_text, 18.0, Colors::title(false));
|
||||
ui.add_space(1.0);
|
||||
let line_desc = t!("transport.conn_line").replace(":", "");
|
||||
let value = format!("{} {}", NOTCHES, line_desc);
|
||||
ui.label(RichText::new(value).size(15.0).color(Colors::gray()));
|
||||
ui.add_space(3.0);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.response;
|
||||
let clicked = res.clicked() || res.long_touched();
|
||||
// Setup background and cursor.
|
||||
if res.hovered() {
|
||||
res.on_hover_cursor(CursorIcon::PointingHand);
|
||||
bg_shape.fill = Colors::fill();
|
||||
}
|
||||
ui.painter().set(bg_idx, bg_shape);
|
||||
// Handle clicks on layout.
|
||||
if clicked {
|
||||
self.bridge_conn_line_edit = bridge.connection_line();
|
||||
// Show connection line edit modal.
|
||||
let title = bridge.protocol_name();
|
||||
Modal::new(BRIDGE_CONN_LINE_EDIT_MODAL)
|
||||
.position(ModalPosition::Center)
|
||||
.title(title)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
/// Show bridge connection line QR code scanner.
|
||||
fn show_qr_scan_bridge_modal(&mut self, cb: &dyn PlatformCallbacks) {
|
||||
self.bridge_qr_scan_content = Some(CameraScanContent::default());
|
||||
// Show QR code scan modal.
|
||||
Modal::new(SCAN_BRIDGE_CONN_LINE_MODAL)
|
||||
.position(ModalPosition::CenterTop)
|
||||
.title(t!("scan_qr"))
|
||||
.closeable(false)
|
||||
.show();
|
||||
cb.start_camera();
|
||||
}
|
||||
|
||||
/// Draw bridge connection line input [`Modal`] content.
|
||||
fn bridge_conn_line_edit_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
let on_save = |c: &mut TorSettingsContent| {
|
||||
let bridge = TorConfig::get_bridge().unwrap();
|
||||
if bridge.connection_line() != c.bridge_conn_line_edit {
|
||||
TorBridge::save_bridge_conn_line(&bridge, c.bridge_conn_line_edit.clone());
|
||||
c.settings_changed = true;
|
||||
}
|
||||
Modal::close();
|
||||
};
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("transport.conn_line"))
|
||||
.size(17.0)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Draw connection line text edit.
|
||||
ui.vertical_centered(|ui| {
|
||||
let scroll_id = Id::from(BRIDGE_CONN_LINE_EDIT_MODAL);
|
||||
View::horizontal_line(ui, Colors::item_stroke());
|
||||
ui.add_space(3.0);
|
||||
ScrollArea::both()
|
||||
.id_salt(scroll_id)
|
||||
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
|
||||
.max_height(128.0)
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(7.0);
|
||||
let input_id = scroll_id.with("_input");
|
||||
egui::TextEdit::multiline(&mut self.bridge_conn_line_edit)
|
||||
.id(input_id)
|
||||
.font(egui::TextStyle::Body)
|
||||
.desired_rows(5)
|
||||
.interactive(false)
|
||||
.desired_width(f32::INFINITY)
|
||||
.show(ui);
|
||||
ui.add_space(6.0);
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(2.0);
|
||||
View::horizontal_line(ui, Colors::item_stroke());
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Setup spacing between buttons.
|
||||
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
|
||||
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered_justified(|ui| {
|
||||
// Draw paste button.
|
||||
let paste_text = format!("{} {}", CLIPBOARD_TEXT, t!("paste"));
|
||||
View::button(ui, paste_text, Colors::white_or_black(false), || {
|
||||
self.bridge_conn_line_edit = cb.get_string_from_buffer();
|
||||
});
|
||||
});
|
||||
columns[1].vertical_centered_justified(|ui| {
|
||||
// Draw button to scan bridge QR code.
|
||||
let scan_text = format!("{} {}", SCAN, t!("scan"));
|
||||
View::button(ui, scan_text, Colors::white_or_black(false), || {
|
||||
self.show_qr_scan_bridge_modal(cb);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
View::horizontal_line(ui, Colors::item_stroke());
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Show modal buttons.
|
||||
ui.scope(|ui| {
|
||||
// Setup spacing between buttons.
|
||||
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
|
||||
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered_justified(|ui| {
|
||||
View::button(
|
||||
ui,
|
||||
t!("modal.cancel"),
|
||||
Colors::white_or_black(false),
|
||||
|| {
|
||||
// Close modal.
|
||||
Modal::close();
|
||||
},
|
||||
);
|
||||
});
|
||||
columns[1].vertical_centered_justified(|ui| {
|
||||
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
|
||||
on_save(self);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw bridge binary setup content.
|
||||
fn bridge_bin_ui(&mut self, ui: &mut egui::Ui, bridge: &TorBridge, cb: &dyn PlatformCallbacks) {
|
||||
// Draw round background.
|
||||
let mut rect = ui.available_rect_before_wrap();
|
||||
rect.set_height(56.0);
|
||||
let r = View::item_rounding(1, 2, false);
|
||||
let bg = Colors::fill_lite();
|
||||
let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside);
|
||||
let bg_idx = ui.painter().add(bg_shape.clone());
|
||||
|
||||
let res = ui
|
||||
.scope_builder(
|
||||
UiBuilder::new()
|
||||
.sense(Sense::click())
|
||||
.layout(Layout::right_to_left(Align::Center))
|
||||
.max_rect(rect),
|
||||
|ui| {
|
||||
self.bridge_bin_pick_file.ui(ui, cb, |path| {
|
||||
if bridge.binary_path() != path {
|
||||
TorBridge::save_bridge_bin_path(bridge, path);
|
||||
self.settings_changed = true;
|
||||
}
|
||||
});
|
||||
let layout_size = ui.available_size();
|
||||
ui.allocate_ui_with_layout(
|
||||
layout_size,
|
||||
Layout::left_to_right(Align::Center),
|
||||
|ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(4.0);
|
||||
let bin_text = bridge.binary_path();
|
||||
View::ellipsize_text(ui, bin_text, 18.0, Colors::title(false));
|
||||
ui.add_space(1.0);
|
||||
let bin_desc = t!("transport.bin_file").replace(":", "");
|
||||
let value = format!("{} {}", TERMINAL, bin_desc);
|
||||
ui.label(RichText::new(value).size(15.0).color(Colors::gray()));
|
||||
ui.add_space(3.0);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.response;
|
||||
let clicked = res.clicked() || res.long_touched();
|
||||
// Setup background and cursor.
|
||||
if res.hovered() {
|
||||
res.on_hover_cursor(CursorIcon::PointingHand);
|
||||
bg_shape.fill = Colors::fill();
|
||||
}
|
||||
ui.painter().set(bg_idx, bg_shape);
|
||||
// Handle clicks on layout.
|
||||
if clicked {
|
||||
self.bridge_bin_path_edit = bridge.binary_path();
|
||||
// Show binary path edit modal.
|
||||
let title = bridge.protocol_name();
|
||||
Modal::new(BRIDGE_BIN_EDIT_MODAL)
|
||||
.position(ModalPosition::CenterTop)
|
||||
.title(title)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw bridge binary input [`Modal`] content.
|
||||
fn bridge_bin_edit_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
let on_save = |c: &mut TorSettingsContent| {
|
||||
let bridge = TorConfig::get_bridge().unwrap();
|
||||
let exists = fs::exists(&c.bridge_bin_path_edit).unwrap_or_default();
|
||||
if !exists {
|
||||
return;
|
||||
}
|
||||
if bridge.binary_path() != c.bridge_bin_path_edit {
|
||||
TorBridge::save_bridge_bin_path(&bridge, c.bridge_bin_path_edit.clone());
|
||||
c.settings_changed = true;
|
||||
}
|
||||
Modal::close();
|
||||
};
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("transport.bin_file"))
|
||||
.size(17.0)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Draw bridge text edit.
|
||||
let mut edit = TextEdit::new(Id::from(BRIDGE_BIN_EDIT_MODAL)).paste();
|
||||
edit.ui(ui, &mut self.bridge_bin_path_edit, cb);
|
||||
if edit.enter_pressed {
|
||||
on_save(self);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
|
||||
// Show modal buttons.
|
||||
ui.scope(|ui| {
|
||||
// Setup spacing between buttons.
|
||||
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
|
||||
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered_justified(|ui| {
|
||||
View::button(
|
||||
ui,
|
||||
t!("modal.cancel"),
|
||||
Colors::white_or_black(false),
|
||||
|| {
|
||||
// Close modal.
|
||||
Modal::close();
|
||||
},
|
||||
);
|
||||
});
|
||||
columns[1].vertical_centered_justified(|ui| {
|
||||
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
|
||||
on_save(self);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ use egui_extras::image::load_svg_bytes_with_size;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::{CHECK_FAT, CHECK_SQUARE, SQUARE};
|
||||
use crate::gui::views::types::LinePosition;
|
||||
@@ -694,27 +693,25 @@ impl View {
|
||||
/// Draw application logo image with name and version.
|
||||
pub fn app_logo_name_version(ui: &mut egui::Ui) {
|
||||
ui.add_space(-1.0);
|
||||
let logo = if AppConfig::dark_theme().unwrap_or(false) {
|
||||
egui::include_image!("../../../img/logo_light.png")
|
||||
} else {
|
||||
egui::include_image!("../../../img/logo.png")
|
||||
};
|
||||
// Goblin mark (white master, tinted for the current theme).
|
||||
let logo = egui::include_image!("../../../img/goblin-logo2.svg");
|
||||
// Show application logo and name.
|
||||
ui.scope(|ui| {
|
||||
ui.set_opacity(0.9);
|
||||
egui::Image::new(logo)
|
||||
.fit_to_exact_size(egui::vec2(182.0, 182.0))
|
||||
.tint(Colors::white_or_black(true))
|
||||
.fit_to_exact_size(egui::vec2(150.0, 150.0))
|
||||
.ui(ui);
|
||||
});
|
||||
ui.add_space(-11.0);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("GRIM")
|
||||
RichText::new("GOBLIN")
|
||||
.size(24.0)
|
||||
.color(Colors::white_or_black(true)),
|
||||
);
|
||||
ui.add_space(-2.0);
|
||||
ui.label(
|
||||
RichText::new(crate::VERSION)
|
||||
RichText::new(format!("Build {}", crate::BUILD))
|
||||
.size(16.0)
|
||||
.color(Colors::title(false)),
|
||||
);
|
||||
|
||||
@@ -16,8 +16,8 @@ use eframe::epaint::RectShape;
|
||||
use egui::os::OperatingSystem;
|
||||
use egui::scroll_area::ScrollBarVisibility;
|
||||
use egui::{
|
||||
Align, CornerRadius, CursorIcon, Id, Layout, Margin, OpenUrl, RichText, ScrollArea, Sense,
|
||||
StrokeKind, UiBuilder,
|
||||
Align, CornerRadius, CursorIcon, Id, Layout, Margin, OpenUrl, ScrollArea, Sense, StrokeKind,
|
||||
UiBuilder,
|
||||
};
|
||||
use egui_async::Bind;
|
||||
use std::time::Duration;
|
||||
@@ -25,10 +25,11 @@ use std::time::Duration;
|
||||
use crate::AppConfig;
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::{
|
||||
ARROW_LEFT, BOOKMARKS, CALENDAR_CHECK, CLOUD_ARROW_DOWN, COMPUTER_TOWER, FOLDER_PLUS, GEAR,
|
||||
GEAR_FINE, GLOBE, GLOBE_SIMPLE, LOCK_KEY, NOTEPAD, PLUS, SIDEBAR_SIMPLE, SUITCASE,
|
||||
ARROW_LEFT, BOOKMARKS, CALENDAR_CHECK, CLOUD_ARROW_DOWN, COMPUTER_TOWER, GEAR, GEAR_FINE,
|
||||
GLOBE, GLOBE_SIMPLE, LOCK_KEY, NOTEPAD, PLUS, SIDEBAR_SIMPLE, SUITCASE,
|
||||
};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::views::goblin::onboarding::OnboardingContent;
|
||||
use crate::gui::views::settings::SettingsContent;
|
||||
use crate::gui::views::types::{
|
||||
ContentContainer, LinePosition, ModalPosition, TitleContentType, TitleType,
|
||||
@@ -64,6 +65,8 @@ pub struct WalletsContent {
|
||||
wallet_content: WalletContent,
|
||||
/// Wallet creation content.
|
||||
creation_content: Option<WalletCreationContent>,
|
||||
/// First-run onboarding content, shown while no wallets exist.
|
||||
onboarding: Option<OnboardingContent>,
|
||||
|
||||
/// Settings content.
|
||||
settings_content: Option<SettingsContent>,
|
||||
@@ -95,6 +98,7 @@ impl Default for WalletsContent {
|
||||
wallet_settings_content: WalletSettingsModal::new(ConnectionMethod::Integrated),
|
||||
wallet_content: WalletContent::default(),
|
||||
creation_content: None,
|
||||
onboarding: None,
|
||||
settings_content: None,
|
||||
check_update: Bind::new(false),
|
||||
update_info: (false, None),
|
||||
@@ -194,15 +198,25 @@ impl ContentContainer for WalletsContent {
|
||||
let showing_settings = self.showing_settings();
|
||||
let creating_wallet = self.creating_wallet();
|
||||
let showing_wallet = self.showing_wallet() && !creating_wallet && !showing_settings;
|
||||
// First-run onboarding owns the surface while no wallets exist (and
|
||||
// keeps it through its identity step, when the new wallet is already
|
||||
// open but not yet selected).
|
||||
let onboarding_active = !showing_wallet && self.onboarding_active();
|
||||
let dual_panel = is_dual_panel_mode(ui);
|
||||
let content_width = ui.available_width();
|
||||
let list_hidden = showing_settings
|
||||
|| creating_wallet
|
||||
|| onboarding_active
|
||||
|| self.wallets.list().is_empty()
|
||||
|| (showing_wallet && (!dual_panel || !AppConfig::show_wallets_at_dual_panel()));
|
||||
|
||||
// Show title panel.
|
||||
self.title_ui(ui, dual_panel, cb);
|
||||
// Show title panel, except over the full-bleed Goblin wallet surface,
|
||||
// onboarding, and the returning-user wallet list (all carry their own
|
||||
// header; the yellow GRIM bar is off-brand on those surfaces).
|
||||
let wallet_list_screen = self.wallet_list_screen();
|
||||
if !showing_wallet && !onboarding_active && !wallet_list_screen {
|
||||
self.title_ui(ui, dual_panel, cb);
|
||||
}
|
||||
|
||||
egui::SidePanel::right("wallet_panel")
|
||||
.resizable(false)
|
||||
@@ -297,6 +311,8 @@ impl ContentContainer for WalletsContent {
|
||||
},
|
||||
fill: if self.showing_settings() {
|
||||
Colors::fill_lite()
|
||||
} else if onboarding_active {
|
||||
Colors::fill()
|
||||
} else {
|
||||
Colors::fill_deep()
|
||||
},
|
||||
@@ -334,20 +350,17 @@ impl ContentContainer for WalletsContent {
|
||||
self.select_wallet(w, None, cb);
|
||||
}
|
||||
}
|
||||
} else if self.wallets.list().is_empty() {
|
||||
View::center_content(ui, 350.0 + View::get_bottom_inset(), |ui| {
|
||||
View::app_logo_name_version(ui);
|
||||
ui.add_space(4.0);
|
||||
|
||||
let text = t!("wallets.create_desc");
|
||||
ui.label(RichText::new(text).size(16.0).color(Colors::gray()));
|
||||
ui.add_space(8.0);
|
||||
// Show wallet creation button.
|
||||
let add_text = format!("{} {}", FOLDER_PLUS, t!("wallets.add"));
|
||||
View::button(ui, add_text, Colors::white_or_black(false), || {
|
||||
self.show_add_wallet_modal();
|
||||
});
|
||||
});
|
||||
} else if onboarding_active {
|
||||
// First-run onboarding replaces the stock empty state;
|
||||
// later wallets still use the list's add-wallet flow.
|
||||
if self.onboarding.is_none() {
|
||||
self.onboarding = Some(OnboardingContent::default());
|
||||
}
|
||||
let ob = self.onboarding.as_mut().unwrap();
|
||||
if let Some(w) = ob.ui(ui, &mut self.wallets, cb) {
|
||||
self.onboarding = None;
|
||||
self.select_wallet(&w, None, cb);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -399,11 +412,30 @@ impl WalletsContent {
|
||||
self.creation_content.is_some()
|
||||
}
|
||||
|
||||
/// Check if first-run onboarding owns the surface (no wallets yet, or
|
||||
/// the onboarding identity step is still finishing).
|
||||
pub fn onboarding_active(&self) -> bool {
|
||||
!self.showing_settings()
|
||||
&& !self.creating_wallet()
|
||||
&& (self.onboarding.is_some() || self.wallets.list().is_empty())
|
||||
}
|
||||
|
||||
/// Check if application settings are showing.
|
||||
pub fn showing_settings(&self) -> bool {
|
||||
self.settings_content.is_some()
|
||||
}
|
||||
|
||||
/// Check if the returning-user wallet list owns the surface (wallets
|
||||
/// exist, none open, not mid-onboarding/creation/settings). On this
|
||||
/// screen the node panel is demoted to an opt-in chip, not a column.
|
||||
pub fn wallet_list_screen(&self) -> bool {
|
||||
!self.showing_wallet()
|
||||
&& !self.onboarding_active()
|
||||
&& !self.showing_settings()
|
||||
&& !self.creating_wallet()
|
||||
&& !self.wallets.list().is_empty()
|
||||
}
|
||||
|
||||
/// Handle data from deeplink or opened file.
|
||||
fn on_data(&mut self, ui: &mut egui::Ui, data: Option<String>, cb: &dyn PlatformCallbacks) {
|
||||
let wallets_size = self.wallets.list().len();
|
||||
@@ -542,6 +574,35 @@ impl WalletsContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Slim header for the wallet-list surface: just the app-settings gear,
|
||||
/// right-aligned. The integrated node moved into the gear's settings — out
|
||||
/// of the list, every node feature still intact.
|
||||
fn wallet_list_header_ui(&mut self, ui: &mut egui::Ui) {
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
// App-settings gear, right-aligned. Drawn manually (the title-bar
|
||||
// button uses dark-on-yellow ink, invisible on this dark surface).
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let (g_rect, g_resp) =
|
||||
ui.allocate_exact_size(egui::Vec2::splat(34.0), Sense::click());
|
||||
ui.painter().text(
|
||||
g_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
GEAR,
|
||||
eframe::epaint::FontId::proportional(20.0),
|
||||
Colors::text(false),
|
||||
);
|
||||
if g_resp
|
||||
.on_hover_cursor(CursorIcon::PointingHand)
|
||||
.on_hover_text("Settings")
|
||||
.clicked()
|
||||
{
|
||||
self.settings_content = Some(SettingsContent::default());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw list of wallets.
|
||||
fn wallet_list_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
ScrollArea::vertical()
|
||||
@@ -550,6 +611,13 @@ impl WalletsContent {
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
|
||||
// Slim goblin header: node-status chip (opens the node
|
||||
// panel) on the left, app-settings gear on the right —
|
||||
// the controls the suppressed yellow title bar used to
|
||||
// carry, restyled to match the open-wallet surface.
|
||||
self.wallet_list_header_ui(ui);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Show application logo and name.
|
||||
View::app_logo_name_version(ui);
|
||||
ui.add_space(15.0);
|
||||
|
||||
@@ -133,7 +133,7 @@ impl MnemonicSetup {
|
||||
}
|
||||
|
||||
/// Draw grid of words for mnemonic phrase.
|
||||
fn word_list_ui(&mut self, ui: &mut egui::Ui, edit: bool) {
|
||||
pub(crate) fn word_list_ui(&mut self, ui: &mut egui::Ui, edit: bool) {
|
||||
ui.add_space(6.0);
|
||||
ui.scope(|ui| {
|
||||
// Setup spacing between columns.
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
mod creation;
|
||||
pub(crate) mod creation;
|
||||
pub mod modals;
|
||||
|
||||
mod content;
|
||||
pub use content::*;
|
||||
|
||||
mod wallet;
|
||||
pub mod wallet;
|
||||
use wallet::*;
|
||||
|
||||
@@ -23,7 +23,6 @@ use crate::gui::views::wallets::wallet::account::WalletAccountContent;
|
||||
use crate::gui::views::wallets::wallet::message::MessageInputContent;
|
||||
use crate::gui::views::wallets::wallet::proof::PaymentProofContent;
|
||||
use crate::gui::views::wallets::wallet::request::{InvoiceRequestContent, SendRequestContent};
|
||||
use crate::gui::views::wallets::wallet::transport::WalletTransportContent;
|
||||
use crate::gui::views::wallets::wallet::types::WalletContentContainer;
|
||||
use crate::gui::views::wallets::wallet::{WalletSettingsContent, WalletTransactionsContent};
|
||||
use crate::gui::views::{Content, Modal, View};
|
||||
@@ -45,13 +44,14 @@ pub struct WalletContent {
|
||||
|
||||
/// Account panel content.
|
||||
pub account_content: WalletAccountContent,
|
||||
/// Transport panel content.
|
||||
pub transport_content: WalletTransportContent,
|
||||
|
||||
/// Invoice request creation [`Modal`] content.
|
||||
invoice_content: Option<InvoiceRequestContent>,
|
||||
/// Send request creation [`Modal`] content.
|
||||
send_content: Option<SendRequestContent>,
|
||||
|
||||
/// Goblin Cash App-style surface (primary UI).
|
||||
goblin: crate::gui::views::goblin::GoblinWalletView,
|
||||
}
|
||||
|
||||
/// Identifier for invoice creation [`Modal`].
|
||||
@@ -83,6 +83,41 @@ impl WalletContentContainer for WalletContent {
|
||||
}
|
||||
|
||||
fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
// Goblin surface is the primary UI. Show a sync screen until data is
|
||||
// ready, then hand the whole surface to the Cash App-style view.
|
||||
let block_nav_goblin = self.block_navigation_on_sync(wallet);
|
||||
if block_nav_goblin || wallet.get_data().is_none() {
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
fill: Colors::fill(),
|
||||
..Default::default()
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
sync_ui(ui, wallet);
|
||||
});
|
||||
self.handle_task_result(wallet);
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
fill: Colors::fill(),
|
||||
..Default::default()
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
self.goblin.ui(ui, wallet, cb);
|
||||
self.handle_task_result(wallet);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl WalletContent {
|
||||
#[allow(dead_code)]
|
||||
fn legacy_container_ui(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
wallet: &Wallet,
|
||||
cb: &dyn PlatformCallbacks,
|
||||
) {
|
||||
let dual_panel = Content::is_dual_panel_mode(ui.ctx());
|
||||
let show_wallets_dual = AppConfig::show_wallets_at_dual_panel();
|
||||
|
||||
@@ -146,11 +181,10 @@ impl WalletContentContainer for WalletContent {
|
||||
}
|
||||
|
||||
// Flag to check if account panel is opened.
|
||||
let top_panel_expanded =
|
||||
self.account_content.can_back() || self.transport_content.can_back();
|
||||
let top_panel_expanded = self.account_content.can_back();
|
||||
|
||||
// Show wallet account content.
|
||||
if !self.transport_content.can_back() && show_account {
|
||||
if show_account {
|
||||
egui::TopBottomPanel::top(Id::from("wallet_account").with(wallet.identifier()))
|
||||
.frame(egui::Frame {
|
||||
inner_margin: Margin {
|
||||
@@ -183,46 +217,6 @@ impl WalletContentContainer for WalletContent {
|
||||
});
|
||||
}
|
||||
|
||||
// Show wallet transport content.
|
||||
if !self.account_content.can_back() && show_account {
|
||||
egui::TopBottomPanel::top(Id::from("wallet_transport").with(wallet.identifier()))
|
||||
.frame(egui::Frame {
|
||||
inner_margin: Margin {
|
||||
left: (View::far_left_inset_margin(ui) + View::content_padding()) as i8,
|
||||
right: (View::get_right_inset() + View::content_padding()) as i8,
|
||||
top: 1.0 as i8,
|
||||
bottom: 1.0 as i8,
|
||||
},
|
||||
fill: if top_panel_expanded {
|
||||
if self.transport_content.qr_address_content.is_some() {
|
||||
Colors::FILL_DEEP
|
||||
} else {
|
||||
Colors::fill_lite()
|
||||
}
|
||||
} else {
|
||||
Colors::TRANSPARENT
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
|
||||
self.transport_content.ui(ui, &wallet, cb);
|
||||
});
|
||||
// Draw content divider lines.
|
||||
let r = {
|
||||
let mut r = rect.clone();
|
||||
r.min.x -= View::content_padding() + View::far_left_inset_margin(ui);
|
||||
r.min.y -= 1.0;
|
||||
r.max.x += View::content_padding() + View::get_right_inset();
|
||||
r
|
||||
};
|
||||
if dual_panel && show_wallets_dual {
|
||||
View::line(ui, LinePosition::LEFT, &r, Colors::item_stroke());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show tab content.
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
@@ -290,9 +284,9 @@ impl Default for WalletContent {
|
||||
txs_content: Some(WalletTransactionsContent::new(None)),
|
||||
settings_content: None,
|
||||
account_content: WalletAccountContent::default(),
|
||||
transport_content: WalletTransportContent::default(),
|
||||
invoice_content: None,
|
||||
send_content: None,
|
||||
goblin: crate::gui::views::goblin::GoblinWalletView::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,10 +298,6 @@ impl WalletContent {
|
||||
t!("scan_qr")
|
||||
} else if self.account_content.show_list {
|
||||
t!("wallets.accounts")
|
||||
} else if self.transport_content.settings_content.is_some() {
|
||||
t!("wallets.transport")
|
||||
} else if self.transport_content.qr_address_content.is_some() {
|
||||
t!("network_mining.address")
|
||||
} else if self.settings_content.is_some() {
|
||||
t!("wallets.settings")
|
||||
} else {
|
||||
@@ -317,16 +307,19 @@ impl WalletContent {
|
||||
|
||||
/// Check if it's possible to go back at navigation stack.
|
||||
pub fn can_back(&self) -> bool {
|
||||
self.account_content.can_back() || self.transport_content.can_back()
|
||||
self.goblin.overlay_active() || self.account_content.can_back()
|
||||
}
|
||||
|
||||
/// Navigate back on navigation stack.
|
||||
pub fn back(&mut self, cb: &dyn PlatformCallbacks) {
|
||||
/// Navigate back on navigation stack. Returns true if not consumed.
|
||||
pub fn back(&mut self, cb: &dyn PlatformCallbacks) -> bool {
|
||||
if self.goblin.overlay_active() {
|
||||
return self.goblin.on_back();
|
||||
}
|
||||
if self.account_content.can_back() {
|
||||
self.account_content.back(cb);
|
||||
} else if self.transport_content.can_back() {
|
||||
self.transport_content.back();
|
||||
return false;
|
||||
}
|
||||
self.goblin.on_back()
|
||||
}
|
||||
|
||||
/// Check when to block tabs navigation on sync progress.
|
||||
|
||||
@@ -27,4 +27,3 @@ mod account;
|
||||
mod message;
|
||||
mod proof;
|
||||
mod request;
|
||||
mod transport;
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
// Copyright 2025 The Grim Developers
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use egui::{Align, CornerRadius, Layout, RichText, StrokeKind};
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::{
|
||||
CIRCLE_HALF, DOTS_THREE_CIRCLE, PLUGS, PLUGS_CONNECTED, POWER, QR_CODE, SHIELD_CHECKERED,
|
||||
SHIELD_SLASH, WARNING_CIRCLE, WRENCH,
|
||||
};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::views::wallets::wallet::transport::settings::WalletTransportSettingsContent;
|
||||
use crate::gui::views::wallets::wallet::types::WalletContentContainer;
|
||||
use crate::gui::views::{Modal, QrCodeContent, View};
|
||||
use crate::tor::{Tor, TorConfig};
|
||||
use crate::wallet::Wallet;
|
||||
|
||||
/// Wallet transport panel content.
|
||||
pub struct WalletTransportContent {
|
||||
/// QR code address content.
|
||||
pub qr_address_content: Option<QrCodeContent>,
|
||||
|
||||
/// Settings content.
|
||||
pub settings_content: Option<WalletTransportSettingsContent>,
|
||||
}
|
||||
|
||||
impl WalletContentContainer for WalletTransportContent {
|
||||
fn modal_ids(&self) -> Vec<&'static str> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn modal_ui(&mut self, _: &mut egui::Ui, _: &Wallet, _: &Modal, _: &dyn PlatformCallbacks) {}
|
||||
|
||||
fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
if let Some(content) = self.qr_address_content.as_mut() {
|
||||
let dark_theme = AppConfig::dark_theme().unwrap_or(false);
|
||||
// Set light theme for better scanning.
|
||||
AppConfig::set_dark_theme(false);
|
||||
crate::setup_visuals(ui.ctx());
|
||||
// Draw QR code content.
|
||||
ui.add_space(6.0);
|
||||
content.ui(ui, cb);
|
||||
ui.vertical_centered_justified(|ui| {
|
||||
View::button(ui, t!("close"), Colors::white_or_black(false), || {
|
||||
self.qr_address_content = None;
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Set color theme back.
|
||||
AppConfig::set_dark_theme(dark_theme);
|
||||
crate::setup_visuals(ui.ctx());
|
||||
} else if let Some(content) = self.settings_content.as_mut() {
|
||||
let mut closed = false;
|
||||
content.ui(ui, wallet, cb, || {
|
||||
closed = true;
|
||||
});
|
||||
if closed {
|
||||
self.settings_content = None;
|
||||
}
|
||||
} else {
|
||||
self.tor_header_ui(ui, wallet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WalletTransportContent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
qr_address_content: None,
|
||||
settings_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WalletTransportContent {
|
||||
/// Check if it's possible to go back at navigation stack.
|
||||
pub fn can_back(&self) -> bool {
|
||||
self.settings_content.is_some() || self.qr_address_content.is_some()
|
||||
}
|
||||
|
||||
/// Navigate back on navigation stack.
|
||||
pub fn back(&mut self) {
|
||||
if let Some(content) = self.settings_content.as_ref() {
|
||||
if content.tor_settings_content.settings_changed {
|
||||
Tor::restart();
|
||||
}
|
||||
self.settings_content = None;
|
||||
} else if self.qr_address_content.is_some() {
|
||||
self.qr_address_content = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw Tor transport header content.
|
||||
fn tor_header_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
|
||||
let data = wallet.get_data();
|
||||
if data.is_none() {
|
||||
return;
|
||||
}
|
||||
let data = data.unwrap();
|
||||
let addr = wallet.slatepack_address().unwrap();
|
||||
|
||||
// Setup layout size.
|
||||
let mut rect = ui.available_rect_before_wrap();
|
||||
rect.set_height(78.0);
|
||||
|
||||
// Draw round background.
|
||||
let info = data.info;
|
||||
let awaiting_balance = info.amount_awaiting_confirmation > 0
|
||||
|| info.amount_awaiting_finalization > 0
|
||||
|| info.amount_locked > 0;
|
||||
let rounding = if awaiting_balance {
|
||||
View::item_rounding(1, 3, false)
|
||||
} else {
|
||||
View::item_rounding(1, 2, false)
|
||||
};
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
rounding,
|
||||
Colors::fill(),
|
||||
View::item_stroke(),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| {
|
||||
// Show button to show QR code address.
|
||||
let r = if awaiting_balance {
|
||||
View::item_rounding(1, 3, true)
|
||||
} else {
|
||||
View::item_rounding(1, 2, true)
|
||||
};
|
||||
View::item_button(ui, r, QR_CODE, None, || {
|
||||
self.qr_address_content =
|
||||
Some(QrCodeContent::new(addr.clone(), false).with_max_size(320.0));
|
||||
});
|
||||
|
||||
let service_id = &wallet.identifier();
|
||||
// Draw button to enable/disable Tor listener for current wallet.
|
||||
if wallet.foreign_api_port().is_some() && wallet.secret_key().is_some() {
|
||||
let port = wallet.foreign_api_port().unwrap();
|
||||
let key = wallet.secret_key().unwrap();
|
||||
if !Tor::is_service_starting(service_id) {
|
||||
if !Tor::is_service_running(service_id) {
|
||||
let r = CornerRadius::default();
|
||||
View::item_button(ui, r, POWER, Some(Colors::green()), || {
|
||||
Tor::start_service(port, key.clone(), service_id);
|
||||
});
|
||||
} else {
|
||||
let r = CornerRadius::default();
|
||||
View::item_button(ui, r, POWER, Some(Colors::red()), || {
|
||||
Tor::stop_service(service_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Draw button to show Tor transport settings.
|
||||
let button_rounding = View::item_rounding(1, 3, true);
|
||||
View::item_button(ui, button_rounding, WRENCH, None, || {
|
||||
self.settings_content = Some(WalletTransportSettingsContent::default());
|
||||
});
|
||||
|
||||
let layout_size = ui.available_size();
|
||||
ui.allocate_ui_with_layout(
|
||||
layout_size,
|
||||
Layout::left_to_right(Align::Center),
|
||||
|ui| {
|
||||
ui.add_space(6.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(3.0);
|
||||
|
||||
let is_running = Tor::is_service_running(service_id);
|
||||
let has_error = Tor::is_service_failed(service_id);
|
||||
let is_starting = Tor::is_service_starting(service_id);
|
||||
let address_color = if is_running && !is_starting {
|
||||
Colors::green()
|
||||
} else if has_error {
|
||||
Colors::red()
|
||||
} else {
|
||||
Colors::inactive_text()
|
||||
};
|
||||
// Show slatepack address text.
|
||||
View::animate_text(ui, addr.clone(), 17.0, address_color, is_starting);
|
||||
ui.add_space(1.0);
|
||||
|
||||
let (icon, text) = if is_starting {
|
||||
(DOTS_THREE_CIRCLE, t!("transport.connecting"))
|
||||
} else if has_error {
|
||||
(WARNING_CIRCLE, t!("transport.conn_error"))
|
||||
} else if is_running {
|
||||
(PLUGS_CONNECTED, t!("transport.connected"))
|
||||
} else if let Some(_) = TorConfig::get_proxy() {
|
||||
(PLUGS_CONNECTED, t!("app_settings.proxy"))
|
||||
} else {
|
||||
(PLUGS, t!("transport.disconnected"))
|
||||
};
|
||||
let status_text = format!("{} {}", icon, text);
|
||||
// Show connection status text.
|
||||
View::ellipsize_text(ui, status_text, 15.0, Colors::text(false));
|
||||
ui.add_space(1.0);
|
||||
|
||||
let bridges_text = if is_starting || has_error {
|
||||
match TorConfig::get_bridge() {
|
||||
None => {
|
||||
format!(
|
||||
"{} {}",
|
||||
SHIELD_SLASH,
|
||||
t!("transport.bridges_disabled")
|
||||
)
|
||||
}
|
||||
Some(b) => {
|
||||
let name = b.protocol_name().to_uppercase();
|
||||
format!(
|
||||
"{} {}",
|
||||
SHIELD_CHECKERED,
|
||||
t!("transport.bridge_name", "b" = name)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
format!("{} {}", CIRCLE_HALF, t!("transport.tor_network"))
|
||||
};
|
||||
// Show bridge info text.
|
||||
ui.label(RichText::new(bridges_text).size(15.0).color(Colors::gray()));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright 2025 The Grim Developers
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
mod content;
|
||||
pub use content::*;
|
||||
|
||||
mod settings;
|
||||