Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90c294bf26 | |||
| 3a69b72d9d | |||
| bdedcba498 | |||
| 037e727756 | |||
| ce024443ac | |||
| 1141f97b22 | |||
| e036a9692a | |||
| ef58f260e8 | |||
| 210c4ab662 | |||
| c0b622f694 | |||
| 42d70e1a5e | |||
| e9f0c3f0e2 | |||
| a437aad2f8 | |||
| d5d1212a44 | |||
| ab2ac7c3ac | |||
| 3db6375459 | |||
| d5ae136cf1 | |||
| a7c2443f3b | |||
| ae0f36d287 | |||
| 9f019edfeb | |||
| 093c5014ef | |||
| 65d8d0f7bd | |||
| d4dcbb115f | |||
| 0bf87a6cff | |||
| a57121959a | |||
| ba8e81ef5f | |||
| e492703a0c | |||
| 89791793ed | |||
| 278a946980 | |||
| e8d71afc7e | |||
| 1f36631777 | |||
| 1e55ef5dfb | |||
| 03c1770892 | |||
| 3c32474f75 | |||
| 30c0ed9a12 | |||
| 22bf3359f5 | |||
| 53e18f06c7 | |||
| c78d7b0e60 | |||
| ce23214d98 | |||
| e6e262009e | |||
| 2478a7c08a | |||
| d95efef896 | |||
| 698806421f | |||
| 61545b767d | |||
| 87f82eb61e | |||
| b3165c3964 | |||
| 4d1db9cb28 | |||
| 138785cf67 | |||
| 9caa2b6809 | |||
| b00719f2f9 | |||
| c83771bbf8 | |||
| 54344bd1d3 | |||
| 23bb845689 | |||
| aa39737d3b | |||
| 5733b9a894 | |||
| 8f1a955f9a | |||
| ae4306febe | |||
| 989fd5b04a | |||
| acf9a140f6 | |||
| db793bc13d | |||
| 71bf9b90e5 | |||
| 5869ff78be | |||
| 300d9cea4c | |||
| 337220299f | |||
| 12f78f3af7 | |||
| bb2e8602ff | |||
| 78e141f319 | |||
| c701f0f480 | |||
| 1e8e0f6526 | |||
| 9262d7429b | |||
| 36e63d4751 | |||
| f0b5410c13 | |||
| 84cc9d663b | |||
| 2235e64bac | |||
| 2b5cb8ad55 | |||
| 1fdbd80282 | |||
| d0cb76fa02 | |||
| 991670d863 | |||
| 55b78b78ef | |||
| 919cfcb71e | |||
| d60e71d1e0 | |||
| 24abc7e7b3 | |||
| 11033b93fe | |||
| dfbd85c7b3 | |||
| 7eb0683646 | |||
| 9dba2163fa | |||
| 313a14b82c | |||
| 222f149fc2 | |||
| 6ea94989bf | |||
| a35fb7956c | |||
| 6a0c2565b5 | |||
| b7c3b95f51 | |||
| 22292ef79c | |||
| cc59be0834 | |||
| 3eff81e18d | |||
| dbc988f9ae | |||
| 9768de2fbd | |||
| 2e6cff9eeb | |||
| ba504aa266 | |||
| 49fbebd4ce | |||
| 72181ec9eb | |||
| 5005d7cb2d | |||
| 96daed3c84 | |||
| f715149302 | |||
| af30af48e4 | |||
| f0b854171c | |||
| 0644807f51 | |||
| 8e1b3ce847 | |||
| fb4f27a88f | |||
| e5d1f0c6db | |||
| f5c449463b | |||
| 7e3f335e79 | |||
| 726e96130c | |||
| 851ae1c565 |
@@ -0,0 +1,21 @@
|
||||
name: Fetch nip44 crate
|
||||
description: >
|
||||
Clone the nip44 crate (v2 + v3 encryption) from our mirror
|
||||
(github.com/2ro/nip44, branch `v3`) into ../nip44, so the
|
||||
`nip44 = { path = "../nip44" }` dependency resolves.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Clone nip44
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEST="$(dirname "$GITHUB_WORKSPACE")/nip44"
|
||||
if [ -e "$DEST/Cargo.toml" ]; then
|
||||
echo "nip44 already present at $DEST"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf "$DEST"
|
||||
git clone --branch v3 --depth 1 https://github.com/2ro/nip44.git "$DEST"
|
||||
echo "nip44 cloned from 2ro/nip44@v3 -> $DEST"
|
||||
@@ -1,23 +0,0 @@
|
||||
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,12 +1,6 @@
|
||||
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
|
||||
@@ -15,8 +9,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
|
||||
# nip44 is a path dep on ../nip44; materialize it before building.
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -27,7 +21,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -38,6 +32,6 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# macOS is DEFERRED until Linux/Windows/Android are polished — so this is
|
||||
# manual-dispatch only for now (no auto-build on release publish). When macOS
|
||||
# is back on the table, re-add `release: { types: [published] }` here and the
|
||||
# macOS job will attach a universal build to each release automatically.
|
||||
# macOS builds on its native runner automatically when a release is published
|
||||
# (the macOS job has no dispatch-only gate). Linux/Windows stay dispatch-only —
|
||||
# they are built locally and uploaded with the release; run the workflow by hand
|
||||
# to (re)build those on runners against an existing tag.
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -23,8 +25,6 @@ permissions:
|
||||
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
# aws-lc-sys (via nym-sdk) needs NASM on native Windows; use its prebuilt NASM.
|
||||
AWS_LC_SYS_PREBUILT_NASM: 1
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
|
||||
@@ -62,11 +62,32 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
|
||||
- name: Package
|
||||
- name: Build MSI installer (cargo-wix / WiX 3 — same packaging as GRIM)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# The .msi is built from wix/main.wxs (the cargo-wix default template:
|
||||
# WixUI_Minimal + launch-after-install), so `cargo wix` wires up the
|
||||
# WixUI/WixUtil extensions, cultures and CargoTargetBinDir for us. The
|
||||
# installer + shortcuts + Add/Remove-Programs entry carry wix/Product.ico
|
||||
# (the yellow Goblin icon). --no-build reuses the release exe above so the
|
||||
# embedded GOBLIN_BUILD number is preserved.
|
||||
cargo install cargo-wix --locked
|
||||
$wix = Get-ChildItem 'C:\Program Files (x86)' -Directory -Filter 'WiX Toolset v3*' -ErrorAction SilentlyContinue | Select-Object -Last 1
|
||||
if (-not $wix) {
|
||||
choco install wixtoolset --no-progress -y | Out-Null
|
||||
$wix = Get-ChildItem 'C:\Program Files (x86)' -Directory -Filter 'WiX Toolset v3*' | Select-Object -Last 1
|
||||
}
|
||||
$env:WIX = "$($wix.FullName)\"
|
||||
$env:PATH = "$($wix.FullName)\bin;$env:PATH"
|
||||
$msi = "goblin-$env:TAG-win-x86_64.msi"
|
||||
cargo wix --no-build --nocapture -o "$msi"
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "$msi")) { throw "cargo wix failed to produce $msi" }
|
||||
(Get-FileHash "$msi" -Algorithm SHA256).Hash.ToLower() + " $msi" | Out-File -Encoding ascii "goblin-$env:TAG-win-x86_64-msi-sha256sum.txt"
|
||||
- name: Package portable zip
|
||||
shell: bash
|
||||
run: |
|
||||
7z a "goblin-$TAG-win-x86_64.zip" ./target/release/goblin.exe
|
||||
@@ -75,6 +96,8 @@ jobs:
|
||||
with:
|
||||
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
files: |
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64.msi
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64-msi-sha256sum.txt
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64.zip
|
||||
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64-sha256sum.txt
|
||||
|
||||
@@ -86,19 +109,33 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- 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
|
||||
- name: Universal binary into Goblin.app bundle
|
||||
run: |
|
||||
# Combine both arches into one universal Mach-O and drop it into the
|
||||
# app bundle's executable slot (CFBundleExecutable=goblin).
|
||||
lipo -create -output goblin \
|
||||
target/aarch64-apple-darwin/release/goblin \
|
||||
target/x86_64-apple-darwin/release/goblin
|
||||
zip "goblin-$TAG-macos-universal.zip" goblin
|
||||
cp goblin macos/Goblin.app/Contents/MacOS/goblin
|
||||
chmod +x macos/Goblin.app/Contents/MacOS/goblin
|
||||
# Drop the placeholder that kept the empty dir tracked in git.
|
||||
rm -f macos/Goblin.app/Contents/MacOS/.gitignore
|
||||
# Ad-hoc sign (no Apple cert in CI). REQUIRED on Apple Silicon: lipo
|
||||
# strips the per-arch signatures cargo/ld add, and an unsigned arm64
|
||||
# Mach-O is killed by the OS. Ad-hoc gives a valid (if unidentified)
|
||||
# signature; users still right-click → Open past Gatekeeper.
|
||||
codesign --force --sign - macos/Goblin.app/Contents/MacOS/goblin
|
||||
codesign --force --sign - macos/Goblin.app
|
||||
# ditto is the macOS-correct way to zip an .app (preserves the bundle
|
||||
# layout, symlinks and permissions; plain `zip` mangles bundles).
|
||||
ditto -c -k --keepParent macos/Goblin.app "goblin-$TAG-macos-universal.zip"
|
||||
shasum -a 256 "goblin-$TAG-macos-universal.zip" > "goblin-$TAG-macos-universal-sha256sum.txt"
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
@@ -7,6 +7,9 @@ android/keystore
|
||||
android/keystore.asc
|
||||
android/keystore.properties
|
||||
android/*.apk
|
||||
android/*.apk.sha256
|
||||
android/*.AppImage
|
||||
android/*.AppImage.sha256
|
||||
android/*sha256sum.txt
|
||||
/.idea
|
||||
.DS_Store
|
||||
@@ -24,3 +27,12 @@ Cargo.toml-e
|
||||
screenshots/
|
||||
# GRIM-canonical build toolchains fetched by scripts/toolchain.sh
|
||||
.toolchains/
|
||||
# Runtime wallet/node artifacts + secrets generated by running locally — NEVER commit
|
||||
.owner_api_secret
|
||||
.foreign_api_secret
|
||||
grin-wallet.log
|
||||
grin-wallet.toml
|
||||
# Internal E2E harnesses — reference funded wallets / live relays; kept on disk,
|
||||
# untracked (mod e2e is gated behind the `e2e-internal` feature)
|
||||
src/wallet/e2e.rs
|
||||
tests/nostr_e2e.rs
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "grim"
|
||||
version = "0.3.6"
|
||||
authors = ["Ardocrat <ardocrat@gri.mw>"]
|
||||
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and the Nym mixnet handled for you."
|
||||
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and Tor handled for you."
|
||||
license = "Apache-2.0"
|
||||
repository = "https://code.gri.mw/GUI/grim"
|
||||
keywords = [ "crypto", "grin", "mimblewimble", "nostr" ]
|
||||
@@ -31,6 +31,20 @@ lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[features]
|
||||
## Default build uses the Tor transport only. The `nym` feature gates the dormant
|
||||
## mixnet path (src/nym/). Cargo resolves OPTIONAL deps into the graph too, so
|
||||
## nym-sdk cannot merely be `optional` — it links a different libsqlite3-sys than
|
||||
## arti (a native-lib `links` conflict Cargo rejects at resolution). The nym
|
||||
## path-deps are therefore commented out below; the module code is retained on
|
||||
## disk but building `--features nym` requires restoring them (and drops arti —
|
||||
## the two transports cannot coexist in one binary, which is why Tor replaced Nym).
|
||||
default = []
|
||||
nym = []
|
||||
## Compiles the internal E2E harness (src/wallet/e2e.rs); off by default and the
|
||||
## file is untracked, so a fresh clone builds without it.
|
||||
e2e-internal = []
|
||||
|
||||
[dependencies]
|
||||
log = "0.4.27"
|
||||
|
||||
@@ -87,19 +101,12 @@ rkv = "0.20.0"
|
||||
usvg = "0.45.1"
|
||||
ring = "0.16.20"
|
||||
hyper = { version = "1.6.0", features = ["full"], package = "hyper" }
|
||||
hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy"] }
|
||||
hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy", "tokio"] }
|
||||
http-body-util = "0.1.3"
|
||||
bytes = "1.11.0"
|
||||
hyper-socks2 = "0.9.1"
|
||||
hyper-proxy2 = "0.1.0"
|
||||
hyper-tls = "0.6.0"
|
||||
## native-tls (via hyper-tls) uses OpenSSL on Linux/Android. Upstream Grim got a
|
||||
## vendored, statically-linked OpenSSL for free through arti's `static` feature;
|
||||
## dropping arti for Nym took that with it, breaking Android/cross builds (no
|
||||
## system OpenSSL for the target) and leaving desktop dynamically linked to
|
||||
## libssl. Restore the vendored build so every target is self-contained. Inert on
|
||||
## Windows/macOS, which use SChannel / Security.framework instead of OpenSSL.
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
async-std = "1.13.2"
|
||||
uuid = { version = "0.8.2", features = ["v4"] }
|
||||
num-bigint = "0.4.6"
|
||||
@@ -107,29 +114,61 @@ num-bigint = "0.4.6"
|
||||
## nostr
|
||||
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
|
||||
nostr-relay-pool = "0.44"
|
||||
## NIP-44 v3 (+ v2) encryption for the NIP-17 backward-compat extension (G4).
|
||||
## Now published to crates.io as v0.3.0 (the M0 deliverable, all upstream test
|
||||
## vectors green) — no local sibling checkout required. secp256k1 0.31, bridged
|
||||
## to nostr-sdk's 0.29 in wrapv3.rs (see the secp256k1 note below).
|
||||
nip44 = "0.3.0"
|
||||
## Only to construct the key types the `nip44` crate takes: nostr-sdk pins
|
||||
## secp256k1 0.29, the nip44 crate 0.31 — bridged via byte arrays in wrapv3.rs.
|
||||
secp256k1 = "0.31"
|
||||
## Scrub the NIP-44 conversation-key buffers (raw ECDH secret) from memory on
|
||||
## drop in wrapv3.rs. Already a transitive dep; pulled in directly here.
|
||||
zeroize = "1"
|
||||
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
|
||||
## rustls is pulled by both our TLS (tungstenite, 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::crypto::ring::default_provider()` reachable. NOTHING here may pull
|
||||
## rustls-platform-verifier — it panics on Android outside a full app context.
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
## TLS-over-tunnel for the mixnet HTTP client (webpki roots, never the platform
|
||||
## verifier — see the rustls note above).
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
|
||||
webpki-roots = "1"
|
||||
|
||||
## 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" }
|
||||
## Tor — embedded arti (the DIALING half only: connect OUT to the relay's .onion,
|
||||
## and to clearnet HTTP hosts through a Tor exit). Copied from our sister wallet
|
||||
## GRIM's proven, shipping engine. Two choices inherited VERBATIM from GRIM: arti
|
||||
## 0.43 across the family, and the native-tls Tor runtime (TokioNativeTlsRuntime),
|
||||
## NOT rustls — this deliberately sidesteps the rustls/ring crypto-provider
|
||||
## conflict fought during the Nym era (our relay/HTTP rustls still uses ring, see
|
||||
## lib.rs; arti's own TLS is native-tls and never touches the rustls provider).
|
||||
## `static` vendors openssl (self-contained Android/cross builds, as GRIM ships);
|
||||
## `onion-service-client` enables dialing .onion. We drop GRIM's `pt-client`
|
||||
## (bridges) and `onion-service-service` (hosting) — Goblin only dials.
|
||||
arti-client = { version = "0.43.0", features = ["static", "onion-service-client"] }
|
||||
tor-rtcompat = { version = "0.43.0", features = ["static"] }
|
||||
|
||||
## Nym mixnet — DORMANT since the Tor transport swap. The mixnet path (src/nym/)
|
||||
## is retained on disk but its deps are COMMENTED OUT, because arti's `tor-dirmgr`
|
||||
## needs libsqlite3-sys 0.34 (rusqlite 0.36) while nym-sdk's credential-storage
|
||||
## needs libsqlite3-sys 0.30 (sqlx) and BOTH link the native `sqlite3` library —
|
||||
## Cargo forbids two packages linking the same native lib, and it rejects this at
|
||||
## RESOLUTION even for optional/unused deps. The two transports therefore cannot
|
||||
## coexist in one binary (exactly why Tor replaced Nym). To build the old path,
|
||||
## restore these three deps and build `--features nym` (which then drops arti).
|
||||
## Full deletion is a later phase; for now the code stays on disk for reference.
|
||||
## Path deps into the local nym checkout, PINNED at rev
|
||||
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b ("http-api-client: preconfigured
|
||||
## webpki roots on Android").
|
||||
# nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
|
||||
# smolmix = { path = "../nym/smolmix/core" }
|
||||
# hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
|
||||
|
||||
## NIP-98 payload hashing
|
||||
sha2 = "0.10.8"
|
||||
@@ -146,6 +185,11 @@ nokhwa = { version = "0.10.10", default-features = false, features = ["input-msm
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
nokhwa = { version = "0.10.10", default-features = false, features = ["input-avfoundation", "output-threaded"] }
|
||||
## Objective-C runtime shim for the macOS `goblin:` deep-link bridge (src/lib.rs,
|
||||
## mac_deeplink). Already in the macOS build graph via nokhwa/cocoa/metal/wgpu, so
|
||||
## this pulls no new crate — it just lets us name it directly. Not compiled on any
|
||||
## other platform.
|
||||
objc = "0.2"
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
env_logger = "0.11.3"
|
||||
@@ -156,6 +200,18 @@ arboard = "3.2.0"
|
||||
rfd = "0.17.2"
|
||||
interprocess = { version = "2.2.1", features = ["tokio"] }
|
||||
|
||||
## native-tls (via hyper-tls) uses OpenSSL only on Linux/Android. Upstream Grim
|
||||
## got a vendored, statically-linked OpenSSL for free through arti's `static`
|
||||
## feature; dropping arti for Nym took that with it, breaking Android/cross
|
||||
## builds (no system OpenSSL for the target) and leaving desktop dynamically
|
||||
## linked to libssl. Restore the vendored build for exactly those two targets so
|
||||
## each is self-contained. Windows (SChannel) and macOS (Security.framework)
|
||||
## don't use OpenSSL at all, so they must NOT pull it — building openssl-src
|
||||
## there is both pointless and fragile (the Windows MSVC runner's bash perl is
|
||||
## missing modules its Configure needs).
|
||||
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.15.0"
|
||||
jni = "0.21.1"
|
||||
@@ -166,9 +222,21 @@ eframe = { version = "0.33.2", default-features = false, features = ["glow", "an
|
||||
[build-dependencies]
|
||||
built = "0.8.0"
|
||||
|
||||
# Windows hosts only: embed the Goblin icon (wix/Product.ico) into goblin.exe via
|
||||
# build.rs. Not compiled on Linux/macOS/Android hosts, so other builds are
|
||||
# unaffected.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
serde_yaml = "0.9"
|
||||
## Re-expose deps that already live in the main graph so the E2E harness can be
|
||||
## exercised and its logs captured. No new compiles — same versions unify.
|
||||
log = "0.4.27"
|
||||
env_logger = "0.11.3"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
|
||||
@@ -4,26 +4,30 @@
|
||||
|
||||
# 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.
|
||||
Goblin is a private, pay-by-username wallet for [GRIN ツ](https://grin.mw) - confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain.
|
||||
|
||||
Instead of passing slatepack files back and forth, you **pay a `@username` (or an `npub`)** and the payment is delivered for you as an **end-to-end encrypted message over [nostr](https://github.com/nostr-protocol/nips), routed through the [Nym mixnet](https://nym.com)**. Relays only ever see ciphertext — never the amount, the sender, or the recipient — and the mixnet hides who is talking to whom at the network layer.
|
||||
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 [Tor](https://www.torproject.org)**. Relays only ever see ciphertext - never the amount, the sender, or the recipient. Tor hides your IP from the relay; the relay and encryption hide the rest - content, sender, timing.
|
||||
|
||||
Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN node/wallet engine and layers a Nostr-native, mobile-first payments experience on top.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Send to people** — pay a `@username` or `npub`; the GRIN slatepack travels as a [NIP-17](https://nips.nostr.com/17) gift-wrapped DM ([kind 1059](https://nostrbook.dev/kinds/1059)) over the Nym mixnet and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once.
|
||||
- **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).
|
||||
- **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 Tor and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once.
|
||||
- **Manual slatepacks too** - when you need to pay or get paid without a handle, **Settings → Wallet → Slatepacks** exposes the classic by-hand flow: create a slatepack to send, or paste one to receive, finalize, or pay.
|
||||
- **Open-to-pay links** - a `goblin:` or `nostr:` pay link, or a scanned checkout QR, opens the wallet straight to a prefilled review screen (recipient, amount and note filled in, ready to hold-to-send) on desktop, macOS and Android.
|
||||
- **Proofs on request** - payments can include a native Grin payment proof when the payment request asks for one, off by default, shown on the review screen. An ordinary person-to-person send carries none.
|
||||
- **In-app identity** - a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `name` comes from the goblin.st identity service.
|
||||
- **Private by construction** - GRIN's address-less, confidential chain; your payments and identity (nostr relays, NIP-05 lookups, price) are routed through [Tor](https://www.torproject.org), so who-pays-whom never touches the clear net. The GRIN node connection - block sync and broadcasting your transaction - is direct: public chain data, the same for everyone, and not tied to your identity. Keys, names and history stay on your device.
|
||||
- **Configurable amount pairing** - show balances against a world currency, Bitcoin, or sats (rates fetched over Tor), or turn the preview off.
|
||||
- **News on Home** - the latest post from the official Goblin news key (a [kind 30023](https://nostrbook.dev/kinds/30023) long-form article) appears on the Home screen in your wallet's language, falling back to English; it stays hidden when there is nothing to show, and only ever shows the newest article.
|
||||
- **Cross-platform** - Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
|
||||
|
||||
## How a payment travels
|
||||
|
||||
```
|
||||
you ──slatepack──▶ NIP-17 gift wrap (kind 1059, NIP-44 encrypted)
|
||||
│
|
||||
Nym mixnet (5-hop)
|
||||
Tor
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
your relays recipient's DM relays (kind 10050)
|
||||
@@ -32,7 +36,7 @@ Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN nod
|
||||
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)).
|
||||
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)). Tor hides your IP from the relay; the relay and the encryption above hide the rest - content, sender, timing.
|
||||
|
||||
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**.
|
||||
|
||||
@@ -40,13 +44,15 @@ Both parties only need one relay in common. The default set is the Goblin relay
|
||||
|
||||
### Desktop (Linux / macOS / Windows)
|
||||
|
||||
Goblin links [Tor](https://www.torproject.org) **in-process** via [arti](https://gitlab.torproject.org/tpo/core/arti) - the wallet is a single self-contained binary, no sidecar, nothing separate to install:
|
||||
|
||||
```
|
||||
git submodule update --init --recursive
|
||||
cargo build --release
|
||||
./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.
|
||||
Goblin's identity and payment traffic (nostr relays, NIP-05 lookups and price fetches) rides Tor: every relay, the money-path relay included, is reached over a Tor exit to its ordinary clearnet host. The GRIN node connection (block sync and transaction broadcast) is **not** routed through Tor: it connects directly, as it carries only public chain data that isn't linked to your wallet.
|
||||
|
||||
### Android
|
||||
|
||||
@@ -60,7 +66,7 @@ Install the Android SDK / NDK, then from the repo root:
|
||||
|
||||
## 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.
|
||||
The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration and release (names are never transferred - on a key rotation you release the old name and re-register, or import your existing identity). The wallet is fully usable - and fully anonymous - without it. Avatars aren't stored or served - clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -11,8 +11,15 @@ android {
|
||||
applicationId "st.goblin.wallet"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 5
|
||||
versionName "0.3.6"
|
||||
// Version tracks Goblin's build number (GOBLIN_BUILD) so the Android
|
||||
// package identity moves forward with every release instead of freezing.
|
||||
// Same env the Rust side stamps into crate::BUILD; the in-app updater
|
||||
// compares build numbers, and this keeps versionCode (Android's
|
||||
// upgrade/downgrade integer) in lockstep. Bare local gradle → fallback.
|
||||
// ponytail: fallback keeps a keyless ./gradlew working; CI/release sets the env
|
||||
def goblinBuild = (System.getenv("GOBLIN_BUILD") ?: "").trim()
|
||||
versionCode goblinBuild.isEmpty() ? 5 : goblinBuild.toInteger()
|
||||
versionName goblinBuild.isEmpty() ? "0.3.6" : goblinBuild
|
||||
}
|
||||
|
||||
lint {
|
||||
|
||||
@@ -59,6 +59,16 @@
|
||||
<data android:pathPattern=".*\\.slatepack" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Goblin payment deep link: web "Open in Goblin" buttons and
|
||||
goblin: QR/links open the wallet straight to the send-review
|
||||
screen. BROWSABLE so a browser/click can resolve it. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="goblin" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="android.app.lib_name" android:value="grim" />
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ public class BackgroundService extends Service {
|
||||
private boolean mStopped = false;
|
||||
|
||||
private static final int NOTIFICATION_ID = 1;
|
||||
// One-shot "payment received" notification, separate from the persistent
|
||||
// sync notification above.
|
||||
private static final int PAYMENT_NOTIFICATION_ID = 2;
|
||||
private static final String PAYMENT_CHANNEL_ID = "PaymentReceived";
|
||||
// One-shot "payment requested" notification (someone asking us to pay them),
|
||||
// separate from both the sync (id=1) and received-payment (id=2) notifications.
|
||||
private static final int REQUEST_NOTIFICATION_ID = 3;
|
||||
private static final String REQUEST_CHANNEL_ID = "PaymentRequested";
|
||||
private NotificationCompat.Builder mNotificationBuilder;
|
||||
|
||||
private String mNotificationContentText = "";
|
||||
@@ -70,12 +78,13 @@ public class BackgroundService extends Service {
|
||||
if (Build.VERSION.SDK_INT > 25) {
|
||||
startStopIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID);
|
||||
}
|
||||
if (canStart) {
|
||||
startStopIntent.setAction(ACTION_START_NODE);
|
||||
PendingIntent i = PendingIntent
|
||||
.getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
|
||||
mNotificationBuilder.addAction(R.drawable.ic_start, getStartText(), i);
|
||||
} else if (canStop) {
|
||||
// Goblin's background job is the light Nostr-over-Nym payment
|
||||
// listen (the "Listening for payments" status); the heavy
|
||||
// integrated node is never STARTED from this notification --
|
||||
// Goblin defaults to an external node, so the GRIM "Enable"
|
||||
// action is removed. Only offer STOP as a safety valve if the
|
||||
// node is somehow already running (started elsewhere).
|
||||
if (canStop) {
|
||||
startStopIntent.setAction(ACTION_STOP_NODE);
|
||||
PendingIntent i = PendingIntent
|
||||
.getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
|
||||
@@ -189,6 +198,74 @@ public class BackgroundService extends Service {
|
||||
notificationManager.cancel(NOTIFICATION_ID);
|
||||
}
|
||||
|
||||
// Show a one-shot "payment received" notification (id=2), separate from
|
||||
// the persistent sync notification (id=1). Called from native code via
|
||||
// MainActivity when a payment slatepack is received over nostr, possibly
|
||||
// while the app is backgrounded. Localization of the fixed strings is a
|
||||
// follow-up (text is composed here at Java side).
|
||||
public static void notifyPaymentReceived(Context context, String name, String amount) {
|
||||
NotificationManager manager = context.getSystemService(NotificationManager.class);
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
// High-importance channel so the notification pops with sound + vibration.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
PAYMENT_CHANNEL_ID, "Payments", NotificationManager.IMPORTANCE_HIGH
|
||||
);
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
Intent i = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_IMMUTABLE);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, PAYMENT_CHANNEL_ID)
|
||||
.setContentTitle("Payment received")
|
||||
.setContentText(name + " paid " + amount + " ツ")
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||
.setContentIntent(pendingIntent);
|
||||
try {
|
||||
manager.notify(PAYMENT_NOTIFICATION_ID, builder.build());
|
||||
} catch (SecurityException e) {
|
||||
// POST_NOTIFICATIONS not granted: skip the notification, never the payment.
|
||||
}
|
||||
}
|
||||
|
||||
// Show a one-shot "payment requested" notification (id=3), separate from both
|
||||
// the persistent sync notification (id=1) and the received-payment one (id=2).
|
||||
// Called from native code via MainActivity when a payment request (Invoice1)
|
||||
// arrives over nostr, possibly while the app is backgrounded. Mirrors
|
||||
// notifyPaymentReceived; strings are composed here Java-side.
|
||||
public static void notifyPaymentRequested(Context context, String name, String amount) {
|
||||
NotificationManager manager = context.getSystemService(NotificationManager.class);
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
// High-importance channel so the notification pops with sound + vibration.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
REQUEST_CHANNEL_ID, "Payment requests", NotificationManager.IMPORTANCE_HIGH
|
||||
);
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
Intent i = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_IMMUTABLE);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, REQUEST_CHANNEL_ID)
|
||||
.setContentTitle("Payment requested")
|
||||
.setContentText(name + " requested " + amount + " ツ")
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||
.setContentIntent(pendingIntent);
|
||||
try {
|
||||
manager.notify(REQUEST_NOTIFICATION_ID, builder.build());
|
||||
} catch (SecurityException e) {
|
||||
// POST_NOTIFICATIONS not granted: skip the notification, never the request.
|
||||
}
|
||||
}
|
||||
|
||||
// Start the service.
|
||||
public static void start(Context c) {
|
||||
if (!isServiceRunning(c)) {
|
||||
|
||||
@@ -69,6 +69,9 @@ public class MainActivity extends GameActivity {
|
||||
|
||||
private ActivityResultLauncher<Intent> mFilePickResult = null;
|
||||
private ActivityResultLauncher<Intent> mOpenFilePermissionsResult = null;
|
||||
private ActivityResultLauncher<Intent> mFileSaveResult = null;
|
||||
// Source path (in the share cache) staged by Rust for the next saveFile().
|
||||
private String mPendingSavePath = null;
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
@Override
|
||||
@@ -145,6 +148,32 @@ public class MainActivity extends GameActivity {
|
||||
}
|
||||
});
|
||||
|
||||
// Register file SAVE result (Storage Access Framework CREATE_DOCUMENT):
|
||||
// copy the staged source file into the user-chosen document.
|
||||
mFileSaveResult = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
String src = mPendingSavePath;
|
||||
mPendingSavePath = null;
|
||||
if (result.getResultCode() == Activity.RESULT_OK && src != null) {
|
||||
Intent data = result.getData();
|
||||
if (data != null && data.getData() != null) {
|
||||
Uri uri = data.getData();
|
||||
try (InputStream is = new FileInputStream(new File(src));
|
||||
OutputStream os = getContentResolver().openOutputStream(uri)) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int length;
|
||||
while (is != null && (length = is.read(buffer)) > 0) {
|
||||
os.write(buffer, 0, length);
|
||||
}
|
||||
if (os != null) os.flush();
|
||||
} catch (Exception e) {
|
||||
Log.e("grim", e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listener for display insets (cutouts) to pass values into native code.
|
||||
View content = getWindow().getDecorView().findViewById(android.R.id.content);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> {
|
||||
@@ -206,6 +235,16 @@ public class MainActivity extends GameActivity {
|
||||
String action = intent.getAction();
|
||||
// Check if file was open with the application.
|
||||
if (action != null && action.equals(Intent.ACTION_VIEW)) {
|
||||
Uri data = intent.getData();
|
||||
String scheme = data != null ? data.getScheme() : null;
|
||||
// Goblin payment deep link (goblin: / nostr:): pass the URI text
|
||||
// straight to native code, which routes it to the send-review flow.
|
||||
// These are NOT files, so they must skip the file-descriptor path.
|
||||
if (scheme != null && (scheme.equalsIgnoreCase("goblin")
|
||||
|| scheme.equalsIgnoreCase("nostr"))) {
|
||||
onData(data.toString());
|
||||
return;
|
||||
}
|
||||
Intent i = getIntent();
|
||||
i.setData(intent.getData());
|
||||
setIntent(i);
|
||||
@@ -392,6 +431,18 @@ public class MainActivity extends GameActivity {
|
||||
// Notify native code to stop activity (e.g. node) if app was terminated from recent apps.
|
||||
public native void onTermination();
|
||||
|
||||
// Called from native code to show a "payment received" notification
|
||||
// (BackgroundService id=2) when a payment arrives over nostr.
|
||||
public void notifyPaymentReceived(String name, String amount) {
|
||||
BackgroundService.notifyPaymentReceived(this, name, amount);
|
||||
}
|
||||
|
||||
// Called from native code to show a "payment requested" notification
|
||||
// (BackgroundService id=3) when a payment request arrives over nostr.
|
||||
public void notifyPaymentRequested(String name, String amount) {
|
||||
BackgroundService.notifyPaymentRequested(this, name, amount);
|
||||
}
|
||||
|
||||
// Called from native code to set text into clipboard.
|
||||
public void copyText(String data) {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
@@ -510,6 +561,23 @@ public class MainActivity extends GameActivity {
|
||||
startActivity(Intent.createChooser(intent, "Share data"));
|
||||
}
|
||||
|
||||
// Called from native code to SAVE a staged file to a user-chosen location.
|
||||
// Launches the Storage Access Framework create-document picker; the result
|
||||
// handler copies the staged source file into the chosen document.
|
||||
public void saveFile(String path, String name) {
|
||||
mPendingSavePath = path;
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("application/octet-stream");
|
||||
intent.putExtra(Intent.EXTRA_TITLE, name);
|
||||
try {
|
||||
mFileSaveResult.launch(intent);
|
||||
} catch (android.content.ActivityNotFoundException ex) {
|
||||
Log.e("grim", ex.toString());
|
||||
mPendingSavePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Called from native code to share plain text (e.g. a payment link) via the
|
||||
// system share sheet.
|
||||
public void shareText(String text) {
|
||||
@@ -534,6 +602,22 @@ public class MainActivity extends GameActivity {
|
||||
}
|
||||
}
|
||||
|
||||
// Called from native code to play a tiny "tick" haptic on a successful copy.
|
||||
public void vibrateCopy() {
|
||||
Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||
if (vibrator == null || !vibrator.hasVibrator()) {
|
||||
return;
|
||||
}
|
||||
// One short, light tick — a confirmation, not an alert.
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK));
|
||||
} else if (Build.VERSION.SDK_INT >= 26) {
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE));
|
||||
} else {
|
||||
vibrator.vibrate(20);
|
||||
}
|
||||
}
|
||||
|
||||
// Called from native code to set status-bar icon color to contrast the
|
||||
// in-app theme. white = light icons for a dark background. The app draws
|
||||
// edge-to-edge, so the OS status-bar background is the app's own content;
|
||||
@@ -557,7 +641,12 @@ public class MainActivity extends GameActivity {
|
||||
// Called from native code to pick the file.
|
||||
public void pickFile() {
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.setType("text/*");
|
||||
// Permissive type: custom extensions like .backup have no registered
|
||||
// MIME type, so any narrower filter greys them out in the picker and
|
||||
// locks users out of restoring their identity. Content is validated
|
||||
// on the native side after selection.
|
||||
intent.setType("*/*");
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
try {
|
||||
mFilePickResult.launch(Intent.createChooser(intent, "Pick file"));
|
||||
} catch (android.content.ActivityNotFoundException ex) {
|
||||
|
||||
|
Before Width: | Height: | Size: 984 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 615 B After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 27 KiB |
@@ -46,17 +46,43 @@ fn main() {
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
Command::new("cmd")
|
||||
.args(&["/C", &git_hooks])
|
||||
.args(["/C", &git_hooks])
|
||||
.output()
|
||||
.expect("failed to execute git config for hooks");
|
||||
} else {
|
||||
Command::new("sh")
|
||||
.args(&["-c", &git_hooks])
|
||||
.args(["-c", &git_hooks])
|
||||
.output()
|
||||
.expect("failed to execute git config for hooks");
|
||||
}
|
||||
|
||||
// Goblin links the Nym mixnet SDK in-process (see src/nym/) — no sidecar
|
||||
// subprocess, no bundled/embedded helper binary, and no Tor/webtunnel. There
|
||||
// is nothing transport-related to build or embed here.
|
||||
// Goblin's private transport is Tor via embedded arti (see src/tor/), linked
|
||||
// in-process — no sidecar subprocess and no bundled/embedded helper binary.
|
||||
// There is nothing transport-related to build or embed here.
|
||||
|
||||
// Embed the Goblin icon into goblin.exe so Explorer, the taskbar and Alt-Tab
|
||||
// show it even for the bare exe (the .msi shortcuts already carry it). No-op
|
||||
// on every non-Windows platform.
|
||||
embed_windows_icon();
|
||||
}
|
||||
|
||||
/// Embed `wix/Product.ico` (the yellow Goblin icon) as goblin.exe's application
|
||||
/// icon resource. Gated to Windows hosts — that's where the `winresource`
|
||||
/// build-dependency is compiled and where the MSVC resource compiler (`rc.exe`,
|
||||
/// shipped on the windows-latest runner) is available; our Windows builds are
|
||||
/// always native MSVC, so host == target == windows.
|
||||
#[cfg(windows)]
|
||||
fn embed_windows_icon() {
|
||||
if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("wix/Product.ico");
|
||||
if let Err(e) = res.compile() {
|
||||
// Don't fail the build over the icon — just flag it.
|
||||
println!("cargo:warning=winresource icon embed failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn embed_windows_icon() {}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Goblin Privacy Transport Redesign
|
||||
|
||||
**Status:** Decided — implementation plan · **Date:** 2026-07-04 · **Author:** Architecture
|
||||
**Scope:** Replace the Nym-mixnet transport that carries Goblin's Nostr traffic with embedded Tor, GRIM-style, and rebuild the one privacy property Tor lacks (send/receive timing unlinkability) on our own relay.
|
||||
**Hard constraints (owner):** No clearnet money path · No NYM tokens · No rented/metered privacy bandwidth · In-process only (no sidecar daemon, no system VPN/TUN — must work on Android and iOS).
|
||||
|
||||
---
|
||||
|
||||
## The decision (read this first)
|
||||
|
||||
**Goblin is dropping the Nym mixnet and returning to Tor, embedded in-process, exactly the way our sister wallet GRIM already ships it.** The wallet keeps everything else it does today — payments are still gift-wrapped Nostr messages that our own relay stores and forwards, so a recipient can be paid while offline. The only thing that changes is the private pipe *underneath* the relay connection: instead of tunnelling to the relay across the Nym mixnet, the wallet dials the relay's **pinned `.onion` address over embedded arti** (Tor written in Rust).
|
||||
|
||||
We do not invent our own Tor engine. We **copy GRIM's** — a proven ~1,300-line engine in four files (`grim/src/tor/`) that already runs in production on desktop and Android. And because a mixnet's real gift was hiding the *timing* link between a sender uploading a payment and a recipient downloading it — something Tor alone does not do — **we rebuild that timing privacy on our own relay**: it holds each incoming gift-wrap and releases it to the recipient after a short randomized (Poisson) delay. Same fuzzing a mixnet performs, done by the one server we fully control, unmetered, and unable to collapse the way rented mixnet bandwidth did.
|
||||
|
||||
This trades exactly one thing: a real distributed mixnet resists a *global passive adversary* who can watch the whole internet at once. A single relay plus Tor does not. That adversary is out of scope for a low-value payments wallet, and Tor never claimed to stop it either. In return we get a transport that is mature, free, unmetered, has a huge anonymity set, is lighter on the battery, and — measured where the user actually waits — **faster**.
|
||||
|
||||
---
|
||||
|
||||
## Why we are leaving the mixnet: confirmed root cause
|
||||
|
||||
This is not a transient outage we can wait out. It is **removal by design**, traced to Nym's own source:
|
||||
|
||||
- A credential-less Nym client is granted a hard-coded `FREE_TESTNET_BANDWIDTH_VALUE = 64 GiB`, and that grant **expires at the next UTC-midnight rollover** (`ecash_today()`).
|
||||
- An entry gateway only honours that free grant while `enforce_zk_nyms = false`.
|
||||
- Public entry gateways are flipping `enforce_zk_nyms = true` **network-wide** as Nym rolls out paid, NYM-token-gated "zk-nym ticketbook" bandwidth (≈ 225 NYM for ~25 GB).
|
||||
|
||||
So the free tier the wallet floated on is **testnet scaffolding Nym is actively deleting**, and its failure recurs *on a schedule*. It went dark on us more than once. The only supported replacement — paid ticketbooks — **requires holding NYM tokens** (an owner red line) and offers no sats/Lightning purchase path. A payments wallet cannot stand on a foundation that disappears at midnight and can only be rented back with a specific speculative token. The mixnet, as we were able to consume it, is a dead end.
|
||||
|
||||
Tor has none of these properties. It is free, unmetered, has no token, no bonding, no grant to expire, and the largest anonymity set of any deployed privacy network. It runs in-process on a phone, and GRIM has already proven the whole embedded path.
|
||||
|
||||
---
|
||||
|
||||
## Threat model: what we are actually protecting
|
||||
|
||||
We reason about leakage at six levels, sender → relay → receiver:
|
||||
|
||||
| Level | Leak | Status under the Tor plan |
|
||||
|---|---|---|
|
||||
| **L1 network** | sender/receiver IP as seen by the relay and on-path observers | **Covered by Tor** — an onion connection has no exit node, so the relay and every hop see a Tor address, never the phone's IP. |
|
||||
| **L2 timing** | correlation of send-time with receive-time (the mixnet's core property) | **Rebuilt on our relay** — Poisson release delay (see below), plus NIP-59 timestamp backdating already in the wallet. |
|
||||
| **L3 volume** | message-size correlation | NIP-44 v2 padding blunts; gift-wraps are already near-uniform at payments volume. |
|
||||
| **L4 relay-visible** | recipient p-tags, kinds, subscription filters, connection cadence | NIP-59 gift-wrap blunts; unchanged by the transport swap. |
|
||||
| **L5 content** | message plaintext | **Already solved** — NIP-44 v2 inside NIP-59 gift-wrap (kind 1059). |
|
||||
| **L6 long-term intersection** | repeated-pattern deanonymization | NIP-59 blunts; unchanged. |
|
||||
|
||||
Our **realistic adversary** is the relay operator, ISPs, near-endpoint observers, and chain-analysts — **not a global passive adversary (GPA)**. GPA-resistance is explicitly out of scope for a low-value payments wallet; Tor itself states plainly that it "does not defend against" an attacker who can watch **both ends** of a circuit. We inherit that boundary knowingly and, for the adversary that actually matters, we cover every level.
|
||||
|
||||
---
|
||||
|
||||
## The architecture
|
||||
|
||||
The whole change is contained because Goblin already isolates its transport behind one trait. Payments still ride the relay-mediated, store-and-forward model unchanged. Four things move:
|
||||
|
||||
**1. The relay hosts an onion service.** Plain, mature **system Tor** runs on the relay's own server (`torrc`: `HiddenServiceDir` + `HiddenServicePort 443 127.0.0.1:443`) and forwards the onion straight to the relay's existing secure-websocket port. This is battle-tested C-Tor doing the hosting half — the strongest, most-audited part of the Tor codebase — and it replaces the custom `floonet-mixexit` byte-pipe binary entirely.
|
||||
|
||||
**2. The wallet dials that onion over embedded arti.** Goblin only needs the **dialing half** of Tor — connect *out* to the relay's onion. It never hosts a service (that is what makes it simpler than GRIM, which hosts an onion to *receive*). `arti-client`'s `TorClient::connect()` returns a `DataStream` that implements `AsyncRead + AsyncWrite` — a drop-in for the byte source the wallet feeds to its websocket layer today.
|
||||
|
||||
**3. We copy GRIM's Tor engine rather than write our own.** GRIM's `src/tor/` is ~1,300 lines across four files (`config.rs`, `mod.rs`, `tor.rs`, `types.rs`) and is already in production. Two technical choices we inherit verbatim because GRIM already paid for them:
|
||||
- **arti 0.43 across the whole arti family** (`arti-client`, `tor-rtcompat`, `tor-config`, `tor-hsservice`, `tor-hsrproxy`, `tor-keymgr`, `tor-llcrypto`, `tor-hscrypto` — all `0.43.0`).
|
||||
- **The native-tls Tor runtime** (`TokioNativeTlsRuntime`), **not rustls**. This deliberately sidesteps the rustls crypto-provider (ring) conflict we fought all through the Nym era. We take GRIM's known-good TLS path and never re-open that wound.
|
||||
|
||||
**4. The two code seams that carry private traffic switch to Tor; the money node does not.** Exactly two paths leave the device with anything sensitive on them: the **relay websocket**, and the **one HTTP helper** that carries all the small lookups (names at `goblin.st`, relay hints, the pinned-pool refresh, price, avatars). Both re-route through arti. The **Grin blockchain node stays on the clear internet exactly as today, unchanged** — it never sees who is paying whom, only opaque transaction data, and Tor-wrapping it would buy nothing but latency.
|
||||
|
||||
**The pin lives where the old Nym exit pin lived.** Our relay-pool gist already carries a per-relay field for exactly this, and its parser is deliberately tolerant of unknown fields (no `deny_unknown_fields`, `version` stays `1`). So we add an `onion` address next to each relay entry and **older builds simply ignore it** — no flag day, no schema break. The plumbing that already resolves the co-located Nym `exit` for a relay URL generalizes one-for-one to resolve its `onion`.
|
||||
|
||||
---
|
||||
|
||||
## Recovering timing privacy: Poisson on the relay
|
||||
|
||||
Here is the one property we cannot get from Tor for free, and how we rebuild it.
|
||||
|
||||
A mixnet's genuine value was **timing unlinkability**: even someone who can see traffic near both ends cannot match "this sender uploaded at 10:01:03" to "that recipient downloaded at 10:01:04," because the mixnet deliberately shuffles and delays messages so the two events do not line up. Tor is low-latency by design and does *not* do this — a payment flows through as fast as the circuit allows.
|
||||
|
||||
We rebuild it in the one place we fully own: **our relay holds each incoming gift-wrap and releases it to the recipient after a randomized, exponentially-distributed (Poisson) delay.** This is the same fuzzing a mixnet performs, collapsed onto a single hop we operate ourselves — unmetered, always on, and immune to the rented-bandwidth failure that just killed Nym for us.
|
||||
|
||||
**The elegant part: this costs the user nothing they can see.** The sender's on-screen "Sent" clears the moment the relay **confirms it holds the message** — not when the recipient receives it. Delivery to the recipient is already asynchronous and invisible (they might be offline for hours). The Poisson delay lands *entirely inside that already-invisible gap.* We buy back the mixnet's timing privacy at **zero visible latency cost**. And it stacks on top of a fuzz the wallet already applies: NIP-59 backdates every gift-wrap's timestamp by a random offset of up to two days, so even the timestamps on the wire are decorrelated from real send time.
|
||||
|
||||
**The honest trade-off, stated plainly:** a real distributed mixnet spreads its mixing across many independent nodes, which is what lets it resist a global passive adversary watching the entire network at once. Our single relay plus Tor does not — a party who could simultaneously observe our relay *and* the recipient's Tor guard *and* correlate the Poisson-delayed release could, in principle, still link the two ends. That is the global-adversary threat, it is out of scope for a Grin payments wallet, and Tor never defended against it either. For the adversary who actually exists — the relay operator, an ISP, a network snoop, a chain-analyst — relay-side Poisson closes the timing gap. We give up one theoretical guarantee and gain reliability, speed, and battery.
|
||||
|
||||
---
|
||||
|
||||
## What we must preserve (load-bearing, transport-agnostic)
|
||||
|
||||
Three things in today's wallet are not Nym-specific and must survive the swap intact.
|
||||
|
||||
**1. The "connection is genuinely live and carrying traffic" readiness signal.** The UI carefully refuses to show "Connected" until a relay is *actually subscribed and carrying traffic on the current tunnel*, not merely until the pipe opened (`transport_ready()` / `warm_up()` / relay-gated generation counter in `src/nym/nymproc.rs`). This is what prevents a reassuring-but-false "Connected" over a tunnel that cannot yet deliver. **Keep the mechanism; re-point it at Tor** — readiness becomes "arti has bootstrapped, the onion circuit is up, and a required relay is subscribed on it."
|
||||
|
||||
**2. The read-back confirm on sending (keep verbatim).** The wallet does **not** report a payment "Sent" on a transport-write success. It performs a genuine read-back: after publishing, it polls the target relays for the event id until one confirms it actually **holds the gift-wrap**, or a timeout is hit — in which case it surfaces failure so the caller retries instead of silently dropping money (`src/nostr/client.rs`, the SILENT-LOSS GUARD loop). This is pure money-safety and is completely transport-agnostic. **Keep the logic exactly as written.** Only the *comments* that say "over the scoped Nym exit / mixnet transport" need their wording generalized to "the transport"; the guard itself does not change a line, and it is in fact the very mechanism that makes relay-side Poisson safe (the sender is told "Sent" precisely when the relay confirms it holds the message).
|
||||
|
||||
**3. User-facing copy that says "Nym" / "Mixnet."** The strings need rewording to Tor across all six locales (`locales/en.yml` plus `tr`, `fr`, `de`, `ru`, `zh-CN`): `connected_nym` ("Connected over Nym" → "Connected over Tor"), `connecting_nym`, `nym_ready`, `mixnet_routing`, `network_value` ("MW + Nym mixnet + nostr"), `privacy_value` ("Mimblewimble + Nym"), `over_mixnet`, `rates_note`, `send_like_message_body`, `row_delivery_val`, `row_privacy_val`, and the onboarding `intro` that describes "a five-hop network." These become plain, honest Tor language — see the forum post at the end for the tone.
|
||||
|
||||
---
|
||||
|
||||
## Mobile reality (told straight)
|
||||
|
||||
**Android is solved.** GRIM already ships embedded Tor on Android and the recipe is copyable. It comes down to two things: **build Tor into the app's native library** (arti compiles into the same `.so` the rest of the Rust already lives in — no separate process, no sidecar), and **set a few environment variables before the Rust runtime starts.** The critical one is `ARTI_FS_DISABLE_PERMISSION_CHECKS=true` (GRIM sets it in `MainActivity.java` before loading native code): arti's `fs-mistrust` layer normally refuses to start if its state directory has "too-open" Unix permissions, and Android's app-sandbox filesystem always trips that check — so without this flag Tor simply never boots on a phone. This is a known, small, copy-paste fix, not a research problem.
|
||||
|
||||
**iOS is green-field and needs its own spike.** The arti library should compile for iOS — nothing about it is Android-specific — but nobody on our side has shipped it there yet, so we treat it as unproven until we have. Two things to flag going in: (a) we run **without pluggable-transport bridges** on iOS, because the platform won't let an app spawn the helper processes those need — plain Tor only, which is fine for our reach-the-relay use case; and (b) the same `fs-mistrust` and state-directory questions need answering against iOS's sandbox. **Action: an iOS Tor spike is a named prerequisite before we claim iOS support.** Android does not wait on it.
|
||||
|
||||
---
|
||||
|
||||
## Integration surface (code anchors)
|
||||
|
||||
The transport is isolated behind one trait, so this is a contained swap, not a rewrite.
|
||||
|
||||
| Seam | Location today | Change |
|
||||
|---|---|---|
|
||||
| Transport trait | `nostr-relay-pool` `WebSocketTransport::connect(url, mode, timeout) -> (WebSocketSink, WebSocketStream)`; impl `NymWebSocketTransport` at **`src/nym/transport.rs:47`** | New arti-backed impl in a new `src/tor/`, engine copied from GRIM. |
|
||||
| Byte source | Nym `open_stream` at **`src/nym/streamexit.rs`** (returns `AsyncRead + AsyncWrite`) | Swap for arti `TorClient::connect(<onion>:443)` → `DataStream`. Keep the rest of the path. |
|
||||
| TLS + ws wrap | `client_async_tls(url, stream)` at **`src/nym/transport.rs:126` / `:158`** (SNI = relay host) | **Unchanged.** |
|
||||
| Injection point | `Client::builder().websocket_transport(...)` in **`src/nostr/client.rs`** | Inject `TorWebSocketTransport`. |
|
||||
| HTTP chokepoint | `http_request_bytes()` **`src/nym/mod.rs:71`** / `http_request()` **`src/nym/mod.rs:111`** — carries NIP-05 / name authority (`src/nostr/nip05.rs`), NIP-11 hints, pool gist, price, avatars | Re-route through arti: Tor→onion for the `goblin.st` name authority (pin a second onion); Tor→clearnet for the gist + price + avatars. **Grin node stays clearnet as today.** |
|
||||
| Pin plumbing (forward-safe) | `PoolRelay` at **`src/nostr/pool.rs:86`**; `exit: Option<String>` at **`:105`**; serde tolerant (no `deny_unknown_fields`); `exit_for()` **`:157`**, `exit_for_host()` **`:170`**, `has_exit()` **`:187`** | Add an `onion` field beside `exit` in the gist **without breaking old builds**; keep `version:1`. `exit_for*`/`has_exit` generalize to `onion_for*`/`has_onion`. |
|
||||
| Readiness lifecycle (**preserve**) | `warm_up()` **`src/nym/nymproc.rs:107`**, `transport_ready()` relay-gated readiness **`:127`–`:135`** | Re-point at Tor: bootstrapped + onion up + relay subscribed. Keep the gating semantics. |
|
||||
| Confirm-before-sent (**preserve verbatim**) | SILENT-LOSS GUARD read-back loop, doc at **`src/nostr/client.rs:63`** | Unchanged logic. Only reword the Nym-specific comments to "the transport." |
|
||||
| Footprint retired | `src/nym/*` = **2,842 lines** (`nymproc` 1073, `dns` 662, `streamexit` 465, `mod` 411, `transport` 231) + `nym-sdk`/`smolmix` path-deps + `floonet-mixexit` | Deleted once Tor is proven. Net code likely **shrinks** — most of `nymproc` exists only to fight Nym flakiness (gateway race / probe / condemn). |
|
||||
|
||||
The live pool already ships the floonet relay inline (`pool.rs:69` pins `wss://relay.floonet.dev` with its Nym `exit`). We add its `onion` alongside, in the same gist, and roll forward.
|
||||
|
||||
---
|
||||
|
||||
## What the wallet will feel like on Tor
|
||||
|
||||
An honest, moment-by-moment read on whether each everyday interaction gets faster, stays the same, or gets slower than it was on the mixnet. Short version: on pure speed, Tor is a lateral-to-favorable move everywhere the user actually waits.
|
||||
|
||||
**Receiving / listening — same-to-better, and never a spinner.** The wallet does not poll for incoming payments. It holds a **live, open subscription** to the relay, and payments are *pushed* down that already-open connection and processed with no human watching. That is invisible on either transport. The difference is that on Tor each pushed message arrives quicker (no per-hop mixing delay), and the deliberate Poisson privacy-delay hides inside the already-invisible delivery gap. Nothing the user sees changes; what is behind it is faster. **Verdict: same-to-better.**
|
||||
|
||||
**Sending — faster where it matters most.** The only part of a send the user actually watches is the marquee moment: find the recipient's relay, publish the wrapped payment, and wait for the relay to confirm it holds it. On the mixnet that moment paid three separate taxes — a per-message mixing delay, a fixed ~3-second stream-settle, and multi-fragment delivery lag. On Tor all three are gone. So this spinner should feel **shorter**, not longer. Everything after it — building the reply, finalizing, posting the finished transaction to the Grin node — is background work, and the node post is plain clear-internet exactly as before. **Verdict: faster where it matters most.**
|
||||
|
||||
**Invoices / requests — same-to-faster, dominated by the human.** There is no separate "invoice" message type on the wire; a request is the same gift-wrapped DM as a payment, just with a human approval gate in front of it. Transport latency matters even less here because a person is tapping "approve." **Verdict: same-to-faster, dominated by the human step.**
|
||||
|
||||
**Name and identity lookups — fast, unchanged in feel.** Resolving `name@goblin.st` is a single HTTP GET behind a short "Searching…" spinner — the one genuinely blocking lookup in the app. Everything else (reverse name lookups, avatars, relay hints, pool refresh, price) is cached, quiet background work the user never waits on. A warm keep-alive connection pool over Tor makes repeat lookups cheap. **Verdict: fast and unchanged in feel — provided we keep the connection warm.**
|
||||
|
||||
**Cold start — simpler, comparable, warm-able.** The mixnet needed *two* separate mixnet clients racing each other for bandwidth grants, plus a whole sequencer, just to get connected "in seconds instead of a minute." Tor is **one** bootstrap — dramatically simpler. It overlaps with app launch, so it is mostly invisible; its only visible edge is the very first send of a session if Tor has not finished bootstrapping yet, which would surface as a slightly longer first "Sending…." Warming the circuit at launch hides even that. **Verdict: simpler, comparable, warm-able.**
|
||||
|
||||
**Battery / always-listening — improves.** A persistent Tor circuit is lighter than a live mixnet client: there is no continuous cover-traffic machinery to run and no per-hop delay work to perform. The wallet's existing "the connection died, rebuild it" logic and its background/foreground handling map cleanly onto Tor circuits. **Verdict: battery should improve.**
|
||||
|
||||
**Net verdict.** On pure speed and feel, Tor is lateral-to-favorable everywhere the user actually waits — and better in the two places they wait most (sending and cold start). What we trade is not speed. It is the mixnet's global-adversary timing guarantee, and we largely rebuild even that, for the realistic adversary, with relay-side Poisson.
|
||||
|
||||
---
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 0 — iOS + mobile spike (de-risk before commit)
|
||||
**Goal:** prove the copied engine drops into the seam on the platforms we are less sure of.
|
||||
**Tasks:** (a) stand up `arti-client` → connect a test `.onion:443` → `client_async_tls` → Nostr ws handshake, proving `DataStream` satisfies the transport seam on desktop; (b) confirm the Android recipe holds in a Goblin dev build (native-lib link + `ARTI_FS_DISABLE_PERMISSION_CHECKS`); (c) **the iOS spike** — does arti compile and bootstrap inside the iOS sandbox, plain-Tor (no PT bridges), and where does `fs-mistrust` land.
|
||||
**Exit criteria:** a Nostr ws session to the relay over an onion, in acceptable time, on a real Android device; a clear yes/no + punch-list for iOS.
|
||||
**Risk:** low on desktop/Android (GRIM shipped it); iOS unknown, which is why it is Phase 0.
|
||||
|
||||
### Phase 1 — Onion service on the relay
|
||||
**Goal:** the relay is reachable over a stable `.onion`.
|
||||
**Tasks:** system-Tor `torrc` onion service on the floonet box fronting the relay's ws port; publish and pin the `onion` in the gist beside the existing entry; add a second onion for the `goblin.st` name authority. Enable Vanguards on the service side.
|
||||
**Validation:** `torify` a plain Nostr client to the onion and complete a handshake.
|
||||
**Risk:** low — mature C-Tor doing the hosting half.
|
||||
|
||||
### Phase 2 — Wallet transport swap
|
||||
**Goal:** all Nostr + HTTP traffic rides Tor; Nym is off the live path.
|
||||
**Tasks:** copy GRIM's `src/tor/` into Goblin; implement `WebSocketTransport` against arti `DataStream`; re-route `http_request*` (Tor→onion for the authority, Tor→clearnet for gist/price/avatars); re-point `warm_up()`/`transport_ready()` at Tor readiness; keep the confirm-before-sent guard; **no clearnet fallback for the relay path — fail loudly**.
|
||||
**Validation:** a real payment between two devices completes over the onion, with the read-back confirm firing.
|
||||
**Risk:** low-medium — mitigated by copying a proven engine and native-tls.
|
||||
|
||||
### Phase 3 — Relay-side Poisson (timing privacy)
|
||||
**Goal:** send-time and receive-time are decorrelated for the realistic adversary.
|
||||
**Tasks:** the relay holds each inbound gift-wrap and releases it to the recipient after an exponentially-distributed delay (mean tuned so it stays inside the invisible delivery gap); confirm the sender's "Sent" still fires on *hold*, not on delivery, so the delay is free to the user.
|
||||
**Validation:** relay logs + on-path capture show release timing decorrelated from arrival timing; sender UX latency unchanged.
|
||||
**Risk:** low — it is a delay queue on a server we own.
|
||||
|
||||
### Phase 4 — Copy reword + iOS follow-through
|
||||
**Goal:** the product says "Tor," honestly, everywhere; iOS is either shipped or explicitly deferred.
|
||||
**Tasks:** reword all Nym/Mixnet strings across the six locales; publish the forum post; land iOS per the Phase-0 punch-list or file it as a tracked follow-up.
|
||||
**Validation:** locale drift test green; no "Nym"/"Mixnet" left in user-facing copy.
|
||||
**Risk:** low.
|
||||
|
||||
### Phase 5 — Retire Nym
|
||||
**Goal:** shed the dead dependency and shrink the binary.
|
||||
**Tasks:** delete `src/nym/*`, the `nym-sdk`/`smolmix` path-deps, and `floonet-mixexit`; shrink the Android native lib; simplify the all-in-one floonet package to **relay + torrc-onion + name-authority-onion**.
|
||||
**Validation:** clean build, smaller Android lib, green E2E.
|
||||
**Risk:** low — deletion after the replacement is proven.
|
||||
|
||||
---
|
||||
|
||||
## Migration & rollout
|
||||
|
||||
The pin format is forward-safe (serde-tolerant, `version:1` preserved), so rollout is graceful:
|
||||
|
||||
1. Ship the **Tor-capable build** via the in-app updater (GitHub releases).
|
||||
2. Run the **system-Tor onion service alongside** the existing relay through the transition.
|
||||
3. As users update, they pick up the `onion` pin from the gist and connect over Tor.
|
||||
4. **Old Nym pins go dark gracefully** — Nym is failing on its own schedule anyway, so there is no working state to "cut over" from and no regression to manage.
|
||||
|
||||
No coordinated flag day. The network is already in the failure state the new build fixes.
|
||||
|
||||
---
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| arti **onion-client** maturity ("not yet as secure as C-Tor") | Mature **system-Tor** hosts the service side; Vanguards on the service; onion-client is actively hardening and we are only the dialer. |
|
||||
| **iOS** unproven | Named Phase-0 spike; Android does not wait on it; plain-Tor (no PT bridges) is the accepted iOS mode. |
|
||||
| **Android build** friction | Direct copy of GRIM's working recipe — native-lib link + `ARTI_FS_DISABLE_PERMISSION_CHECKS`; native-tls dodges the rustls/ring provider conflict. |
|
||||
| **Bootstrap latency** on first send | Warm the circuit at launch; keep the "Connecting…" readiness UX; the confirm-before-sent guard makes a slow first send safe, never lost. |
|
||||
| **Poisson delay tuned too long** (feels laggy to recipient) | Tune mean to sit inside the already-async delivery gap; sender "Sent" fires on relay-hold regardless, so it is never on the user's critical path. |
|
||||
| **Single-relay availability** | The relay was always a required component; onion descriptor HA via a 24/7 host (and onionbalance if needed). |
|
||||
|
||||
---
|
||||
|
||||
## Far-future option (not part of this plan)
|
||||
|
||||
If we ever want to close the one remaining gap — resistance to a global passive adversary — a self-run, tokenless, unbonded mixnet layer could in principle sit *on top of* this Tor foundation (wallet → Tor → our own entry → mixnodes → relay). We are **not building it.** It leans on exactly the free Nym grant that is being deleted, it adds latency, and it defends against a threat a Grin payments wallet does not face. It is noted here only so the door is documented, not opened. **Tor is the plan.**
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
Return to Tor, embedded in-process, copied straight from GRIM's proven engine (arti 0.43, native-tls), dialing the relay's pinned onion — and rebuild the mixnet's one real advantage, timing unlinkability, as a Poisson release delay on the relay we already own and fully control. Keep the relay-mediated store-and-forward model, the genuine-readiness signal, and the read-back confirm-before-sent guard exactly as they are. Android is solved today; iOS gets a named spike. We give up one theoretical guarantee against a global adversary that is out of scope, and in exchange the wallet becomes more reliable, lighter on the battery, faster where users wait — and free of any token or rented bandwidth that can go dark at midnight.
|
||||
|
||||
---
|
||||
|
||||
## Forum post: why we're moving from the mixnet to Tor
|
||||
|
||||
*(Ready to publish, owner's voice.)*
|
||||
|
||||
**We're switching Goblin's private plumbing from the Nym mixnet back to Tor. Here's the honest why.**
|
||||
|
||||
When I built Goblin's privacy layer on the Nym mixnet, I meant it. A mixnet is the strongest metadata-privacy tool we have — it doesn't just hide your IP, it deliberately shuffles the timing of messages so nobody can even tell that *you sending* and *someone receiving* are the same payment. For a money wallet, that's exactly the property you want. I wasn't hedging when I picked it.
|
||||
|
||||
But a payments wallet has to stand on ground that doesn't move, and the ground moved. The free bandwidth tier that Goblin relied on turns out to be temporary testnet scaffolding that Nym is actively removing — it's written right into their code to expire, and their public gateways are switching over to a paid model. And the paid model means holding a specific crypto token to buy bandwidth. That's not a foundation I'm willing to build your money on. It went dark on us more than once, on a schedule, and "your payments work unless it's the wrong time of day" is not something I'll ship.
|
||||
|
||||
So we're going back to **Tor** — the most battle-tested privacy network on the internet, with no token, no rented bandwidth, and nothing to expire. And we're embedding it **right inside the app**, the same way our sibling wallet GRIM already does. No separate program to install, no server in the middle you have to trust. It just works, on your phone, out of the box.
|
||||
|
||||
Here's what that means for you, in plain terms. Tor hides your IP address — from our own relay and from your internet provider — so nobody watching the network can see it's *you* sending a payment. And the one thing Tor doesn't do on its own — that clever timing-shuffle a mixnet does — **we rebuild ourselves**: our relay briefly holds each payment and releases it on a randomized delay, so nobody can match "you sent" to "they received." You won't feel that delay, because it happens in the gap where a payment is already in flight. Your screen says "Sent" the instant our relay has your payment safely in hand.
|
||||
|
||||
And honestly? It's **better** day to day. Tor is faster for the moments you actually wait on — sending a payment, opening the app — because it skips the mixnet's built-in delays. It's easier on your battery. And it's simpler and more reliable, because there's no metered bandwidth left to run out.
|
||||
|
||||
I'll be straight about the one thing we give up: a full mixnet can resist an adversary powerful enough to watch the *entire* internet at once. Tor doesn't claim to stop that, and neither do we — it's not the threat a Grin payments wallet realistically faces. For every attacker that actually exists, you're covered.
|
||||
|
||||
Faster, more reliable, works on your phone, no tokens, no rented bandwidth to fail. That's the trade, and I'm glad to make it.
|
||||
@@ -0,0 +1,379 @@
|
||||
# Goblin transactions — how a payment works, end to end
|
||||
|
||||
This document explains the full lifecycle of a Goblin payment: how money moves,
|
||||
every status it passes through, and the small guarantees that keep it safe. It is
|
||||
written against the code in `src/nostr/` and `src/wallet/` — function names and
|
||||
files are cited (line numbers drift, so they aren't).
|
||||
|
||||
---
|
||||
|
||||
## 1. The big picture: two layers
|
||||
|
||||
A Goblin payment is **a Grin transaction wrapped in a private nostr message**.
|
||||
|
||||
1. **Grin layer (the money).** Grin/Mimblewimble transactions are *interactive*:
|
||||
the sender and recipient exchange a "slate" that passes through states until
|
||||
it's finalized and posted on-chain. There are no addresses and no amounts on
|
||||
the chain. Goblin reuses GRIM's full Grin node + wallet engine for this.
|
||||
|
||||
2. **Nostr layer (the delivery).** Instead of making you hand slate files back
|
||||
and forth, Goblin delivers each slate as an **end-to-end-encrypted nostr
|
||||
direct message**, routed through **Tor**. You pay a `username` or
|
||||
`npub`; the recipient's wallet applies the slate automatically.
|
||||
|
||||
The slate is the payload; nostr is the transport. Everything below is about how
|
||||
those two layers move together and what state is tracked at each step.
|
||||
|
||||
### Slate states (Grin layer)
|
||||
|
||||
Interactive Grin slates pass through numbered states. Goblin uses two flows:
|
||||
|
||||
| Flow | States | Who builds what |
|
||||
| --- | --- | --- |
|
||||
| **Standard** (sender pushes money) | `Standard1` → `Standard2` → `Standard3` | Sender builds S1 (locks their outputs), recipient replies S2, sender finalizes S3 and posts |
|
||||
| **Invoice** (recipient pulls money) | `Invoice1` → `Invoice2` → `Invoice3` | Requester builds I1 (the ask), payer replies I2 (pays), requester finalizes I3 and posts |
|
||||
|
||||
### Status + direction (Goblin's nostr metadata)
|
||||
|
||||
For each payment Goblin stores a `TxNostrMeta` (`src/nostr/types.rs`) keyed by
|
||||
slate id, with a **direction** and a **status**:
|
||||
|
||||
`NostrTxDirection`:
|
||||
- `Sent` — we pushed funds (we created S1).
|
||||
- `Received` — we were paid (we replied S2).
|
||||
- `RequestedByUs` — we issued an invoice (we created I1).
|
||||
- `RequestedOfUs` — someone invoiced us and we paid it (we replied I2).
|
||||
|
||||
`NostrSendStatus`:
|
||||
- `Created` — slate built locally, DM not dispatched yet (durable checkpoint).
|
||||
- `AwaitingS2` — S1 sent, waiting for the recipient's S2 reply.
|
||||
- `ReceivedNoReply` — we processed an incoming S1 (or I1) and built our reply, but haven't dispatched it yet (crash-recovery point).
|
||||
- `RepliedS2` — our S2 reply was dispatched (we received a payment).
|
||||
- `AwaitingI2` — our I1 invoice was sent, waiting for the payer's I2.
|
||||
- `PaidAwaitingFinalize` — we paid an invoice (sent I2); the requester finalizes.
|
||||
- `Finalized` — slate finalized and posted on-chain.
|
||||
- `SendFailed` — DM dispatch failed; eligible for retry.
|
||||
- `Cancelled` — cancelled locally (manual cancel or 24h expiry).
|
||||
|
||||
`Finalized` and `Cancelled` are **terminal**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Identity & addressing
|
||||
|
||||
Your nostr identity is a key that is **deliberately not derived from your wallet
|
||||
seed** (`src/nostr/identity.rs`) — so you can rotate it any time to stay
|
||||
unlinkable without ever touching your funds. It's stored encrypted at rest
|
||||
(NIP-49 ncryptsec under your wallet password).
|
||||
|
||||
You can optionally claim a human-readable **`username`** from a **name authority**
|
||||
(a NIP-05 server). The authority is configurable (Settings → Identity → Name
|
||||
authority; `NostrConfig::{nip05_server, home_domain, set_nip05_server}`), which is
|
||||
what makes Goblin **federated**: a user on `bob@otherinstance.com` can pay
|
||||
`alice@goblin.st`, because a full `name@domain` always resolves against that
|
||||
domain's `/.well-known/nostr.json`. Bare names (`alice`) resolve against *your*
|
||||
configured home authority.
|
||||
|
||||
Display rules (`data::display_name`, no `@` ever shown):
|
||||
- A local **petname** wins.
|
||||
- A verified name on **your home authority** shows bare (`alice`) + a check.
|
||||
- A verified name on a **foreign authority** shows `alice · domain` + a check, so
|
||||
it can never masquerade as a home name.
|
||||
- Otherwise: a short npub.
|
||||
|
||||
Names are kept fresh: see [§11 Name freshness](#11-contacts--name-freshness).
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport: NIP-17 gift wraps over Tor
|
||||
|
||||
A payment DM is built and sent by `send_payment_dm`; control messages (voids) by
|
||||
`send_control_dm` (both in `src/nostr/client.rs`). The message structure
|
||||
(`src/nostr/protocol.rs`):
|
||||
|
||||
- The **payload** is the raw Grin slatepack armor (`BEGINSLATEPACK… ENDSLATEPACK`)
|
||||
inside a kind-14 rumor, prefixed with a human preamble (`[Goblin] GRIN payment
|
||||
message — open in Goblin …`) so a non-Goblin nostr client shows something sane.
|
||||
- **Tags:** a `["goblin","1"]` protocol marker always; an optional `["subject", …]`
|
||||
carrying the user's note (sanitized); control DMs carry
|
||||
`["goblin-action","void", slate_id]` and **no** slatepack.
|
||||
- The rumor is sealed and wrapped as a **kind-1059 gift wrap** (NIP-59 + NIP-44
|
||||
encryption) via nostr-sdk's `send_private_msg_to`. Relays only ever see
|
||||
ciphertext — never the amount, sender, or recipient.
|
||||
|
||||
**Where it's delivered:** the recipient's **kind-10050 DM-relay list**
|
||||
(`fetch_dm_relays`), with our own default relays as fallback, plus any relay
|
||||
hints carried by a pasted `nprofile`. Default relays: `relay.goblin.st`,
|
||||
`relay.damus.io`, `nos.lol` (`src/nostr/relays.rs`), capped at `MAX_DM_RELAYS`.
|
||||
|
||||
**How relays are reached:** every relay connection runs through an in-process
|
||||
**Tor** client (arti, linked directly into the wallet binary — no sidecar), via
|
||||
`TorWebSocketTransport` (`run_service` waits for Tor to be ready before dialing).
|
||||
So the relay never sees your IP: the money-path relay is dialed at its pinned
|
||||
`.onion` address, and any relay without one is reached over a Tor exit to its
|
||||
clearnet host. The Grin *node* connection (block sync + broadcasting the final tx)
|
||||
is direct clearnet — it's public chain data, the same for everyone, not tied to
|
||||
your identity.
|
||||
|
||||
The UI tracks an outgoing attempt via a coarse **send phase**
|
||||
(`client::send_phase`): `IDLE / WORKING / SENT / FAILED / REQUEST_BLOCKED`, with a
|
||||
human-readable failure reason on `FAILED`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Flow A — Pay by username/npub (Standard, we send)
|
||||
|
||||
Dispatched as `WalletTask::NostrSend(amount, npub, note, relay_hints)`; handled in
|
||||
`wallet.rs`.
|
||||
|
||||
1. `w.send(amount)` builds the **S1** slate and **locks our outputs** (the funds
|
||||
are reserved but not yet spent).
|
||||
2. **Save meta `Created`** *before* any network call — this is the durable point
|
||||
a crash recovers from.
|
||||
3. `send_payment_dm` delivers S1 → **`AwaitingS2`** (storing the gift-wrap event
|
||||
id). On dispatch failure → **`SendFailed`** (retryable). On success the contact
|
||||
is created/refreshed (so people you pay appear in Suggested) and a background
|
||||
NIP-05 lookup resolves their name.
|
||||
4. The recipient replies S2 (Flow B). When their S2 gift wrap arrives, the ingest
|
||||
guard routes it to `nostr_finalize_post`, which finalizes **S3** and posts it
|
||||
on-chain → **`Finalized`**.
|
||||
|
||||
```
|
||||
Created ──(S1 sent)──▶ AwaitingS2 ──(their S2 arrives)──▶ Finalized
|
||||
└──(dispatch fails)──▶ SendFailed ──(retry)──▶ AwaitingS2
|
||||
└──(manual cancel / 24h expiry)──▶ Cancelled (outputs unlocked)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Flow B — Receiving (Standard, we're paid)
|
||||
|
||||
Our service subscribes to kind-1059 gift wraps addressed to us
|
||||
(`run_service`). When an **S1** arrives, `handle_wrap` runs the ingest pipeline
|
||||
(§7) and `decide()` classifies it by the **accept policy**:
|
||||
|
||||
- `Everyone` → **AutoReceive** (auto-reply S2).
|
||||
- `Contacts` → AutoReceive if the sender is a known contact, else **SurfaceIncoming** (an approval card).
|
||||
- `Ask` → always SurfaceIncoming.
|
||||
|
||||
**AutoReceive:** `nostr_receive` builds the **S2** reply; we save meta
|
||||
`Received` / **`ReceivedNoReply`**, mark the message processed, then dispatch S2 →
|
||||
**`RepliedS2`**. If the S2 dispatch fails we stay at `ReceivedNoReply` and resend
|
||||
on the next start (§9). The sender then finalizes S3 (Flow A step 4).
|
||||
|
||||
```
|
||||
(incoming S1) ──▶ ReceivedNoReply ──(S2 dispatched)──▶ RepliedS2
|
||||
└──(dispatch fails)──▶ stays ReceivedNoReply → resent on restart
|
||||
```
|
||||
|
||||
**SurfaceIncoming** instead stores a `PaymentRequest` (status `Pending`) for the
|
||||
user to approve or decline — see Flow D.
|
||||
|
||||
---
|
||||
|
||||
## 6. Flow C — Request money (Invoice)
|
||||
|
||||
**We request** — `WalletTask::NostrRequest(amount, npub, note, …)`:
|
||||
|
||||
1. First we check the recipient hasn't opted out: `accepts_requests` reads their
|
||||
kind-0 `goblin_accepts_requests` field; an explicit `false` → phase
|
||||
`REQUEST_BLOCKED` and we stop (fail-open: unknown/unreachable = allowed).
|
||||
2. `issue_invoice(amount)` builds **I1** (no outputs locked — it's just an ask).
|
||||
3. Save meta `RequestedByUs / Created`, dispatch I1 → **`AwaitingI2`**.
|
||||
4. When the payer's **I2** arrives, the ingest guard finalizes **I3** and posts →
|
||||
**`Finalized`**.
|
||||
|
||||
**They approve & pay** (the other side of the same flow) is Flow D.
|
||||
|
||||
```
|
||||
Created ──(I1 sent)──▶ AwaitingI2 ──(their I2 arrives)──▶ Finalized
|
||||
└──(SendFailed → retry) └──(cancel / expiry)──▶ Cancelled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Flow D — Approving an incoming request (we pay an invoice)
|
||||
|
||||
Someone's **I1** is *always* surfaced as a `PaymentRequest`, **never auto-paid**
|
||||
(a hard security invariant). It shows in the Requests list. The user can:
|
||||
|
||||
- **Approve** → `WalletTask::NostrPayRequest(rumor_id)`: re-parse the stored
|
||||
slatepack (must still be I1), `nostr_pay` builds **I2** (this is where *we* pay),
|
||||
save meta `RequestedOfUs / ReceivedNoReply`, dispatch I2 → **`PaidAwaitingFinalize`**,
|
||||
mark the request `Approved`. The requester then finalizes I3.
|
||||
A "Paying…" spinner shows while this runs; the card clears on success.
|
||||
- **Decline** → `WalletTask::NostrDeclineRequest(rumor_id)`: mark `Declined` and
|
||||
send a **void** control DM so the requester's side clears too.
|
||||
|
||||
A surfaced incoming *Standard* S1 (from SurfaceIncoming) is approved the same way,
|
||||
but routes through `nostr_receive` (Flow B) rather than `nostr_pay`.
|
||||
|
||||
`RequestStatus`: `Pending → Approved | Declined | Expired | Cancelled`
|
||||
(`Cancelled` = the requester withdrew it via a void).
|
||||
|
||||
---
|
||||
|
||||
## 8. The ingest guard — `decide()`
|
||||
|
||||
Every incoming gift wrap is judged by `decide()` (`src/nostr/ingest.rs`), a
|
||||
**positive allow-list**: anything not explicitly accepted is `Drop`ped. This is
|
||||
the security core. Summary:
|
||||
|
||||
| Incoming state | Condition | Decision |
|
||||
| --- | --- | --- |
|
||||
| `Standard1` | amount 0, or slate already known | **Drop** |
|
||||
| `Standard1` | new, policy `Everyone` (or `Contacts` + known) | **AutoReceive** |
|
||||
| `Standard1` | new, policy `Contacts` + unknown, or `Ask` | **SurfaceIncoming** |
|
||||
| `Standard2` | matches our pending `Sent` tx (status `AwaitingS2/Created/SendFailed`) **and** sender == stored counterparty **and** the tx exists | **FinalizePost** |
|
||||
| `Standard2` | sender mismatch, or status `Cancelled`/`Finalized`, or no meta | **Drop** |
|
||||
| `Invoice1` | amount 0, already known, or incoming-requests disabled | **Drop** |
|
||||
| `Invoice1` | otherwise | **SurfaceRequest** (never auto-pay) |
|
||||
| `Invoice2` | matches our pending `RequestedByUs` tx + sender match | **FinalizePost** |
|
||||
| `Invoice3` / unknown | — | **Drop** |
|
||||
|
||||
Key consequences:
|
||||
- A **late S2 on a cancelled send** falls through to `Drop` — so cancelling is
|
||||
safe even if the recipient's reply is still in flight (the cancel marks the meta
|
||||
`Cancelled` *first*, and `decide()` then drops the S2).
|
||||
- Finalize only happens for a slate we are actually waiting on, from the exact key
|
||||
we sent to.
|
||||
- Invoices are never auto-paid.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cancel & reclaim
|
||||
|
||||
A stuck outgoing send (recipient never replied) locks your outputs. You can
|
||||
reclaim them manually from the receipt's **"Cancel payment"** button, or the 24h
|
||||
auto-expiry does it for you (§10).
|
||||
|
||||
`WalletTask::NostrCancelSend(slate_id)` (`wallet.rs`):
|
||||
1. Take the `cancel_finalize_lock` — this **serializes against a concurrent S2
|
||||
finalize** so the two can't both win (cancel-and-post would be a double action).
|
||||
2. **Re-check live state under the lock.** If the tx is already `Finalized`, or
|
||||
confirmed/posted on-chain → do nothing and return `CancelOutcome::AlreadyCompleted`
|
||||
("This payment already went through and can't be cancelled"). If already
|
||||
`Cancelled` → idempotent success.
|
||||
3. Otherwise mark the meta **`Cancelled` first**, then `w.cancel(tx_id)` to unlock
|
||||
the Grin outputs, then best-effort send a **void** control DM to the recipient
|
||||
(they're likely offline). → `CancelOutcome::Cancelled` ("your funds are
|
||||
available again").
|
||||
|
||||
**Receipt button visibility** (`cancelable_send` gate): shown only while the send
|
||||
is still unanswered — direction `Sent`, status in `{Created, AwaitingS2,
|
||||
SendFailed}`, **not** confirmed, **not** already cancelled, and either it never
|
||||
reached a relay (`SendFailed`, shown immediately) or the grace window
|
||||
(`cancel_grace_secs`, default 600s) has passed. The instant the recipient accepts
|
||||
(status leaves that set) the button disappears.
|
||||
|
||||
**Recipient side / void ordering:** a void control message marks the slate so that
|
||||
if the recipient's wallet later (or earlier) sees the S1, it's dropped — including
|
||||
the **void-before-S1** race, where the void arrives first and is recorded as
|
||||
`void:{slate_id}:{sender}` so the subsequent S1 is dropped.
|
||||
|
||||
There are sibling tasks for the other directions: `NostrCancelOutgoing` (withdraw
|
||||
an invoice we issued) and `NostrDeclineRequest` (decline an incoming request) —
|
||||
both send a void and mark the local record.
|
||||
|
||||
---
|
||||
|
||||
## 10. Auto-expiry (24h)
|
||||
|
||||
`expire_stale` (`src/nostr/client.rs`) runs from the sync loop. Any non-terminal
|
||||
meta older than `expiry_secs` (default 24h) is expired:
|
||||
|
||||
- If it **locked our outputs** (`expiry_locks_outputs`: a `Sent` send in
|
||||
`Created/AwaitingS2/SendFailed`, or a `RequestedOfUs` invoice we paid in
|
||||
`PaidAwaitingFinalize`) → cancel the Grin tx to unlock, and mark meta `Cancelled`.
|
||||
- If it locked nothing of ours (incoming payments, invoices we issued) → just
|
||||
annotate `Cancelled`.
|
||||
- Pending incoming `PaymentRequest`s flip to `Expired`.
|
||||
|
||||
This is the same unlock path as manual cancel; the manual button just lets you act
|
||||
before the 24h.
|
||||
|
||||
---
|
||||
|
||||
## 11. Crash recovery (`reconcile`)
|
||||
|
||||
On service start, `reconcile` (`client.rs`) re-dispatches any pending outgoing
|
||||
message within a 7-day window, by `(direction, status)`:
|
||||
|
||||
| Direction · status | Slate | Action |
|
||||
| --- | --- | --- |
|
||||
| `Sent` · `Created`/`SendFailed` | Standard1 | resend S1 → `AwaitingS2` |
|
||||
| `RequestedByUs` · `Created`/`SendFailed` | Invoice1 | resend I1 → `AwaitingI2` |
|
||||
| `Received` · `ReceivedNoReply` | Standard2 | resend S2 → `RepliedS2` |
|
||||
| `RequestedOfUs` · `ReceivedNoReply` | Invoice2 | resend I2 → `PaidAwaitingFinalize` |
|
||||
|
||||
Because the slatepack text is persisted and the meta is written *before* every
|
||||
dispatch, a crash at any point is recoverable: re-sending an already-delivered
|
||||
message is harmless (the peer dedups it; see §12).
|
||||
|
||||
---
|
||||
|
||||
## 12. Confirmations (X / N)
|
||||
|
||||
A posted Grin tx matures over `min_confirmations` blocks (default 10) before it's
|
||||
spendable. Grin marks a tx `confirmed` at the **first** block, but Goblin's
|
||||
receipt counts toward the spendable threshold so the number actually moves
|
||||
(`data::receipt_detail`):
|
||||
|
||||
- broadcast, no block yet → `0 / N`
|
||||
- on-chain, immature → `count / N` where `count = tip − inclusion_height + 1`
|
||||
- `count ≥ N` → matured (shown as complete; the receipt's network-fee row is shown
|
||||
only for outgoing payments — a recipient pays no fee).
|
||||
|
||||
---
|
||||
|
||||
## 13. Reliability primitives
|
||||
|
||||
- **Dedup / processed markers** (`store::{is_processed, mark_processed,
|
||||
prune_processed}`): every wrap is recorded at three levels — the gift-wrap event
|
||||
id, the inner rumor id, and `slate:{id}:{state}` — so a replayed or re-sent
|
||||
message is processed exactly once. Markers TTL out after 30 days
|
||||
(pruned on start + hourly).
|
||||
- **Rate limiting** (`allow_sender`): per-sender sliding window — 30 events/hour
|
||||
for known contacts, 10/hour for unknowns — plus a global decrypt ceiling
|
||||
(~120 NIP-44 unwraps/min) to bound CPU/battery against fresh-keypair spam. A
|
||||
message dropped purely for the *global* ceiling isn't marked processed, so it can
|
||||
be retried later.
|
||||
- **Seal integrity:** the gift-wrap seal signer must equal the inner rumor author,
|
||||
and self-addressed messages are dropped.
|
||||
- **The cancel/finalize lock** (§9) prevents a cancel and a finalize from both
|
||||
succeeding on the same slate.
|
||||
|
||||
---
|
||||
|
||||
## 14. Name freshness (contacts)
|
||||
|
||||
Cached `@usernames` are re-validated against the name authority on a periodic
|
||||
sweep (`NAME_REVERIFY_INTERVAL_SECS`, ~78s, capped per tick), and once at app open
|
||||
(persisted `last_name_sweep_at`, gated to the interval).
|
||||
`nip05::check` returns `Verified / Mismatch / Unreachable`: a name is only
|
||||
**cleared** (falls back to the npub) on a definitive `Mismatch` (the server says
|
||||
it's gone or now maps to a different key) — never on a transient network failure.
|
||||
This catches released or reassigned names and stops a freed name from
|
||||
impersonating someone. A user-set petname is never touched.
|
||||
|
||||
---
|
||||
|
||||
## 15. File map
|
||||
|
||||
| Concern | File |
|
||||
| --- | --- |
|
||||
| Status / direction / meta types | `src/nostr/types.rs` |
|
||||
| Gift-wrap + control message build/parse | `src/nostr/protocol.rs` |
|
||||
| Service loop, send/receive/finalize, expiry, reconcile, name sweep | `src/nostr/client.rs` |
|
||||
| Ingest allow-list | `src/nostr/ingest.rs` |
|
||||
| Wallet task handlers (NostrSend / Request / PayRequest / CancelSend / finalize) | `src/wallet/wallet.rs` |
|
||||
| Task definitions | `src/wallet/types.rs` |
|
||||
| Metadata + dedup + contacts store | `src/nostr/store.rs` |
|
||||
| NIP-05 resolve / verify / register, name authority | `src/nostr/nip05.rs` |
|
||||
| Identity (key, NIP-49 backup) | `src/nostr/identity.rs` |
|
||||
| Receipt / activity / confirmations / display name | `src/gui/views/goblin/data.rs` |
|
||||
| Relay defaults + name-authority defaults | `src/nostr/relays.rs` |
|
||||
|
||||
---
|
||||
|
||||
🤖 Documentation written with AI pair-programming assistance (Claude).
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Avatar sizing-checkpoint harness: renders the REAL `avatar_tex` (custom
|
||||
//! image, no ring) and `gradient_avatar` across every size the app uses.
|
||||
//! Names never affect the avatar — this just checks sizing by eye.
|
||||
//! Run: `cargo run --example avatar_ring` (screenshots taken externally).
|
||||
|
||||
use eframe::egui;
|
||||
use grim::gui::views::goblin::widgets as w;
|
||||
|
||||
const SIZES: [f32; 6] = [28.0, 40.0, 48.0, 56.0, 72.0, 96.0];
|
||||
const NAMES: [&str; 3] = ["alice", "bob", "carmen"];
|
||||
|
||||
struct App {
|
||||
tex: Vec<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
/// A synthetic "profile photo": diagonal two-tone blend with a light disc, so
|
||||
/// sizing is judged against something photo-like rather than a flat fill.
|
||||
fn photo(ctx: &egui::Context, name: &str, a: [u8; 3], b: [u8; 3]) -> egui::TextureHandle {
|
||||
const N: usize = 128;
|
||||
let mut px = Vec::with_capacity(N * N);
|
||||
for y in 0..N {
|
||||
for x in 0..N {
|
||||
let t = (x + y) as f32 / (2 * N) as f32;
|
||||
let mut r = a[0] as f32 * (1.0 - t) + b[0] as f32 * t;
|
||||
let mut g = a[1] as f32 * (1.0 - t) + b[1] as f32 * t;
|
||||
let mut bl = a[2] as f32 * (1.0 - t) + b[2] as f32 * t;
|
||||
let dx = x as f32 - 44.0;
|
||||
let dy = y as f32 - 40.0;
|
||||
if (dx * dx + dy * dy).sqrt() < 26.0 {
|
||||
r = (r + 90.0).min(255.0);
|
||||
g = (g + 90.0).min(255.0);
|
||||
bl = (bl + 90.0).min(255.0);
|
||||
}
|
||||
px.push(egui::Color32::from_rgb(r as u8, g as u8, bl as u8));
|
||||
}
|
||||
}
|
||||
let img = egui::ColorImage {
|
||||
size: [N, N],
|
||||
source_size: egui::Vec2::splat(N as f32),
|
||||
pixels: px,
|
||||
};
|
||||
ctx.load_texture(name.to_string(), img, Default::default())
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(cc: &eframe::CreationContext) -> Self {
|
||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||
let tex = vec![
|
||||
photo(&cc.egui_ctx, "alice", [180, 120, 90], [90, 60, 120]),
|
||||
photo(&cc.egui_ctx, "bob", [70, 110, 160], [40, 160, 120]),
|
||||
photo(&cc.egui_ctx, "carmen", [160, 70, 90], [220, 170, 80]),
|
||||
];
|
||||
Self { tex }
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _f: &mut eframe::Frame) {
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame::default().fill(egui::Color32::from_rgb(0xFA, 0xFA, 0xF7)))
|
||||
.show(ctx, |ui| {
|
||||
ui.add_space(10.0);
|
||||
ui.heading("avatar sizing sheet (no ring — names never affect the avatar)");
|
||||
ui.add_space(12.0);
|
||||
for (i, name) in NAMES.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.label(format!("{name:>7}"));
|
||||
for size in SIZES {
|
||||
ui.add_space(14.0);
|
||||
w::avatar_tex(ui, &self.tex[i], name, size);
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("anonymous npub (grinmark gradient):");
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.label(" ");
|
||||
for (i, size) in SIZES.iter().enumerate() {
|
||||
ui.add_space(14.0);
|
||||
w::gradient_avatar(ui, &format!("{i}deadbeef{i}"), *size);
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.label("sizes: ");
|
||||
for size in SIZES {
|
||||
ui.add_space(14.0);
|
||||
ui.allocate_ui(egui::Vec2::new(size, 16.0), |ui| {
|
||||
ui.centered_and_justified(|ui| ui.small(format!("{size}")));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let opts = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default().with_inner_size([900.0, 640.0]),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"avatar-ring",
|
||||
opts,
|
||||
Box::new(|cc| Ok(Box::new(App::new(cc)))),
|
||||
)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 10 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="#000000" 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 -1178 133 -2048 -270 -2488 -1152 -57 -115 -92 -149 -92 -89 0 78 123
|
||||
415 199 548 43 74 73 135 67 135 -25 0 -397 -184 -512 -253 -962 -578 -1594
|
||||
-1675 -1594 -2767 0 -209 -2 -210 -154 -95 -393 297 -523 388 -751 525 -321
|
||||
192 -571 311 -843 400 -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 -653 218 -1499 778 -1704 1129 -43 73 -54 78 188 -79 230
|
||||
-149 543 -303 750 -369 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
|
||||
-419 70 -947 328 -1223 597 -98 96 -153 192 -70 122 41 -35 332 -177 487 -238
|
||||
97 -38 146 -55 348 -118 335 -105 983 -129 1280 -47 1070 294 1688 1238 1761
|
||||
2691 16 304 7 325 -82 190 -88 -134 -504 -535 -669 -645 -618 -412 -1228 -507
|
||||
-1895 -296 -192 60 -199 77 -67 163 428 277 628 871 480 1428 -20 75 -38 98
|
||||
-38 48z 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 -310 856 -372 1401 -121 175
|
||||
81 194 76 89 -23 -356 -336 -878 -447 -1310 -278 -224 88 -517 339 -517 443 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 |
@@ -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 |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -1,7 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Name=Goblin
|
||||
Exec=goblin
|
||||
Exec=goblin %u
|
||||
Icon=goblin
|
||||
Type=Application
|
||||
Categories=Finance
|
||||
MimeType=application/x-slatepack;text/plain;
|
||||
MimeType=application/x-slatepack;text/plain;x-scheme-handler/goblin;
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
@@ -5,15 +5,15 @@
|
||||
# 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.
|
||||
# Goblin links the Tor transport (embedded arti) IN-PROCESS, so the AppImage is
|
||||
# one self-contained binary with no sidecar to embed or ship beside it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
platform="${1:-x86_64}"
|
||||
case "${platform}" in
|
||||
x86_64) arch="x86_64-unknown-linux-gnu" ;;
|
||||
arm) arch="aarch64-unknown-linux-gnu" ;;
|
||||
x86_64) arch="x86_64-unknown-linux-gnu"; appimage_arch="x86_64" ;;
|
||||
arm) arch="aarch64-unknown-linux-gnu"; appimage_arch="aarch64" ;;
|
||||
*) echo "Usage: build_release.sh [platform] (platform: 'x86_64' | 'arm')" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -41,7 +41,7 @@ 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
|
||||
# Assemble the AppDir: AppRun IS the goblin binary (Tor/arti linked in), plus the
|
||||
# icon + desktop entry. Nothing else.
|
||||
appdir="linux/Goblin.AppDir"
|
||||
cp "target/${arch}/release/goblin" "${appdir}/AppRun"
|
||||
@@ -51,7 +51,13 @@ 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}"
|
||||
# The type2 runtime must match the target arch. env.sh sets GOBLIN_APPIMAGE_RUNTIME
|
||||
# to the x86_64 runtime; for a non-x86_64 target use the sibling runtime-<arch>.
|
||||
runtime_file="${GOBLIN_APPIMAGE_RUNTIME:-}"
|
||||
if [ "${appimage_arch}" != "x86_64" ] && [ -n "${runtime_file}" ]; then
|
||||
runtime_file="$(dirname "${runtime_file}")/runtime-${appimage_arch}"
|
||||
fi
|
||||
runtime_arg=()
|
||||
[ -n "${GOBLIN_APPIMAGE_RUNTIME:-}" ] && runtime_arg=(--runtime-file "${GOBLIN_APPIMAGE_RUNTIME}")
|
||||
ARCH=x86_64 "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
|
||||
[ -n "${runtime_file}" ] && [ -e "${runtime_file}" ] && runtime_arg=(--runtime-file "${runtime_file}")
|
||||
ARCH="${appimage_arch}" "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
|
||||
echo "built: ${out}"
|
||||
|
||||
@@ -44,7 +44,7 @@ wallets:
|
||||
await_fin_amount: Warten auf die Fertigstellung
|
||||
locked_amount: Gesperrt
|
||||
txs_empty: 'Um Geld manuell oder per Transport zu empfangen oder zu senden, verwenden Sie die Schaltflächen %{message} oder %{transport} unten auf dem Bildschirm. Um die Wallet-Einstellungen zu ändern, drücken Sie %{settings}.'
|
||||
title: Wallets
|
||||
title: Goblin
|
||||
create_desc: Erstellen oder importieren Sie ein bestehendes Wallet mit dem Seed-Phrase.
|
||||
add: Wallet hinzufügen
|
||||
name: 'Name:'
|
||||
@@ -355,4 +355,478 @@ keyboard:
|
||||
m: m
|
||||
m1: ','
|
||||
m2: .
|
||||
m3: '/'
|
||||
m3: '/'
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonym"
|
||||
connected_nym: "Über Tor verbunden"
|
||||
nym_ready: "Tor bereit · Relays…"
|
||||
connecting_nym: "Verbinde mit Tor…"
|
||||
cant_reach_node: "Node nicht erreichbar"
|
||||
node_synced: "Node synchronisiert"
|
||||
syncing: "Synchronisiere…"
|
||||
balance_updating: "Guthaben wird aktualisiert…"
|
||||
balance_stale: "Knoten nicht erreichbar · letzter bekannter Saldo"
|
||||
fiat_unavailable: "Kurs nicht verfügbar"
|
||||
listening: "Wartet auf Zahlungen"
|
||||
block: "Block %{height}"
|
||||
waiting_for_chain: "Warte auf Chain…"
|
||||
nav_wallet: "Wallet"
|
||||
nav_pay: "Zahlen"
|
||||
nav_activity: "Aktivität"
|
||||
nav_receive: "Empfangen"
|
||||
nav_settings: "Einstellungen"
|
||||
activity: "Aktivität"
|
||||
news: "Neuigkeiten"
|
||||
empty_title: "Noch keine Aktivität"
|
||||
empty_sub: "Sende oder empfange grin, um zu starten."
|
||||
recent: "Zuletzt"
|
||||
scan_to_pay: "Zum Zahlen scannen"
|
||||
type_amount: "Betrag eingeben"
|
||||
request: "Anfordern"
|
||||
pay: "Zahlen"
|
||||
enter_amount: "Betrag zum Zahlen oder Anfordern eingeben"
|
||||
activity:
|
||||
canceled: "abgebrochen"
|
||||
pending: "ausstehend"
|
||||
earlier: "Früher"
|
||||
today: "Heute"
|
||||
yesterday: "Gestern"
|
||||
title: "Aktivität"
|
||||
requests: "Anfragen"
|
||||
empty_title: "Noch keine Aktivität"
|
||||
empty_sub: "Deine Zahlungen erscheinen hier."
|
||||
pending_header: "Ausstehend"
|
||||
receipt:
|
||||
title: "Beleg"
|
||||
not_found: "Transaktion nicht gefunden"
|
||||
for_note: "Für %{note}"
|
||||
details: "Transaktionsdetails"
|
||||
canceled: "Abgebrochen"
|
||||
expired: "Abgelaufen"
|
||||
funds_returned: "Guthaben zurückerstattet"
|
||||
complete: "Abgeschlossen"
|
||||
payment_received: "Zahlung empfangen"
|
||||
payment_sent: "Zahlung erfolgreich gesendet"
|
||||
pending: "Ausstehend"
|
||||
confs: "%{c}/%{r} Bestätigungen"
|
||||
waiting_to_confirm: "Warte auf Bestätigung"
|
||||
paying: "Zahlung läuft…"
|
||||
you: "Du"
|
||||
to: "An"
|
||||
from: "Von"
|
||||
nostr: "nostr"
|
||||
fee_none: "Keine"
|
||||
network_fee: "Netzwerkgebühr"
|
||||
privacy: "Privatsphäre"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Transaktion"
|
||||
cancel_request: "Anfrage abbrechen"
|
||||
cancel_send: "Zahlung abbrechen"
|
||||
cancel_send_confirm: "Zum Abbrechen erneut tippen — sie könnten sie noch erhalten"
|
||||
cancel_send_done: "Zahlung abgebrochen — dein Guthaben ist wieder verfügbar"
|
||||
cancel_send_too_late: "Diese Zahlung ist bereits durchgegangen und kann nicht abgebrochen werden"
|
||||
waiting_to_receive: "Warte, bis %{name} empfängt…"
|
||||
request:
|
||||
title: "%{name} fordert an"
|
||||
approve: "Annehmen"
|
||||
decline: "Ablehnen"
|
||||
review_title: "Anfrage prüfen"
|
||||
hold_to_accept: "Zum Annehmen halten"
|
||||
hold_accept_hint: "Halte gedrückt, um diese Anfrage zu bezahlen"
|
||||
receive:
|
||||
title: "Empfangen"
|
||||
requesting: "Fordere %{amt}%{tsu} an — teilen, um bezahlt zu werden"
|
||||
clear_request: "Anfrage löschen"
|
||||
share_handle: "Teile deinen Handle, um bezahlt zu werden"
|
||||
share_npub: "Teile deinen npub, um bezahlt zu werden"
|
||||
copied: "Kopiert"
|
||||
copy_nostr_id: "nostr-ID kopieren"
|
||||
copy_address: "Adresse kopieren"
|
||||
copy_npub: "npub kopieren"
|
||||
share_message: "Bezahl mich auf Goblin (goblin.st) — %{npub}"
|
||||
privacy_note: "Dein Benutzername ist öffentlich. Zahlungsinhalte bleiben im Netzwerk verschlüsselt."
|
||||
privacy_note_npub: "Dein npub ist öffentlich. Zahlungsinhalte bleiben im Netzwerk verschlüsselt."
|
||||
profile:
|
||||
title: "Profil"
|
||||
activity: "Aktivität"
|
||||
no_activity: "Noch keine Aktivität mit ihnen."
|
||||
unblock: "Entsperren"
|
||||
block: "Sperren"
|
||||
blocked_blurb: "Gesperrt — ihre Zahlungen und Anfragen werden verworfen."
|
||||
block_blurb: "Sperren verwirft eingehende Zahlungen und Anfragen von ihnen."
|
||||
settings:
|
||||
title: "Einstellungen"
|
||||
connected_nostr: "Mit nostr verbunden"
|
||||
connecting_relays: "Verbinde mit Relays…"
|
||||
identity: "Identität"
|
||||
copy_npub: "npub kopieren (öffentlich)"
|
||||
rotate_key: "nostr-Schlüssel wechseln"
|
||||
import_identity: "Identität importieren (.backup / nsec)"
|
||||
backup_note: "Gerät wechseln? Sichere BEIDES: deine Seed-Phrase (Guthaben) und deine Identitäts-.backup-Datei (Name + Schlüssel)."
|
||||
wallet: "Wallet"
|
||||
display_unit: "Anzeigeeinheit"
|
||||
relays: "Relays"
|
||||
nostr_relays: "Nostr-Relays"
|
||||
node: "Node"
|
||||
integrated_node: "Einstellungen des integrierten Nodes"
|
||||
node_advanced: "Erweitert"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Manuelle Transaktion"
|
||||
lock_wallet: "Wallet sperren"
|
||||
switch_wallet: "Wallet wechseln"
|
||||
advanced: "Erweitert"
|
||||
privacy: "Privatsphäre"
|
||||
mixnet_routing: "Tor-Routing"
|
||||
messages_lookups: "Nachrichten & Abfragen"
|
||||
auto_accept: "Automatisch annehmen"
|
||||
pairing: "Preiswährung"
|
||||
accept_anyone: "Jeder"
|
||||
accept_contacts: "Nur Kontakte"
|
||||
accept_ask: "Immer fragen"
|
||||
requests: "Anfragen"
|
||||
incoming_requests: "Eingehende Anfragen"
|
||||
incoming_requests_sub: "Erlaube anderen, Geld von dir anzufordern"
|
||||
hide_amounts: "Beträge verbergen"
|
||||
hide_amounts_sub: "Empfangene Beträge in Benachrichtigungen verbergen"
|
||||
language: "Sprache"
|
||||
update_available: "Update verfügbar"
|
||||
appearance: "Erscheinungsbild"
|
||||
theme: "Design"
|
||||
theme_light: "Hell"
|
||||
theme_dark: "Dunkel"
|
||||
theme_yellow: "Gelb"
|
||||
archive: "Archiv"
|
||||
export_archive: "Archiv exportieren"
|
||||
wipe_history: "Zahlungsverlauf löschen"
|
||||
wipe_history_confirm: "Zum Löschen erneut tippen — kann nicht rückgängig gemacht werden"
|
||||
about: "Über"
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
network: "Netzwerk"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Drittanbieter"
|
||||
grim: "GRIM (Upstream-Wallet)"
|
||||
grin_node: "Grin-Node"
|
||||
sp_intro: "Erweitert — rohe slatepacks von Hand austauschen, so wie GRIM es macht. Nur nutzen, wenn du nicht über einen username zahlen oder bezahlt werden kannst."
|
||||
sp_receive_group: "Empfangen oder abschließen"
|
||||
sp_receive_blurb: "Füge einen slatepack ein, den dir jemand gegeben hat. Goblin empfängt die Zahlung, begleicht die Rechnung oder schließt sie ab und sendet sie."
|
||||
sp_process: "Slatepack verarbeiten"
|
||||
sp_paste_first: "Füge zuerst einen slatepack ein."
|
||||
sp_reply_ready: "Antwort bereit — sende sie an den Absender zurück."
|
||||
sp_finalizing: "Schließe ab und sende an die Chain…"
|
||||
sp_create_group: "Zahlung erstellen"
|
||||
sp_create_blurb: "Erstelle einen slatepack zum Übergeben. Der Empfänger nimmt ihn an, sendet die Antwort zurück, und du schließt sie oben ab."
|
||||
sp_amount_hint: "Betrag in grin"
|
||||
sp_addr_hint: "Empfängeradresse (optional)"
|
||||
sp_create: "Slatepack erstellen"
|
||||
sp_ready: "Slatepack bereit — übergib ihn dem Empfänger."
|
||||
sp_amount_gt_zero: "Gib einen Betrag größer als null ein."
|
||||
sp_to_send: "Zu sendender slatepack"
|
||||
sp_copy: "Slatepack kopieren"
|
||||
rotate_line1: "• Du bekommst einen brandneuen ZUFÄLLIGEN Schlüssel; das alte npub empfängt nichts mehr. Es gibt keine Ableitungskette zwischen beiden."
|
||||
rotate_line2: "• Der neue Schlüssel ist NICHT aus deinem Seed wiederherstellbar — sichere die neue nsec direkt nach dem Wechsel."
|
||||
rotate_line3: "• Dein Benutzername wird FREIGEGEBEN — beanspruche direkt danach denselben oder einen neuen Namen (sobald frei, kann ihn auch jede andere Person nehmen)."
|
||||
rotate_line4: "• Zahlungen, die noch an den alten Schlüssel unterwegs sind, WERDEN gestört — warte zuerst, bis ausstehende Zahlungen abgeschlossen sind."
|
||||
rotate_line5: "• Kontakte, die dein npub direkt gespeichert haben, müssen dich neu finden — teile dein neues npub oder den neu gesicherten username."
|
||||
cancel: "Abbrechen"
|
||||
continue: "Weiter"
|
||||
final_confirmation: "Endgültige Bestätigung"
|
||||
rotate_confirm_blurb: "Dies kann in der App nicht rückgängig gemacht werden. Tippe RESET und gib dein Wallet-Passwort ein, um zu wechseln."
|
||||
type_reset: "RESET tippen"
|
||||
wallet_password: "Wallet-Passwort"
|
||||
rotate_key_btn: "Schlüssel wechseln"
|
||||
rotating_key: "Wechsle Schlüssel…"
|
||||
key_rotated: "Schlüssel gewechselt"
|
||||
new_npub: "Neues npub: %{npub}"
|
||||
backup_new_key: "Sichere jetzt den NEUEN geheimen Schlüssel — dein Seed kann ihn nicht wiederherstellen."
|
||||
copy_new_nsec: "Neues nsec-Backup kopieren"
|
||||
done: "Fertig"
|
||||
rotation_failed: "Wechsel fehlgeschlagen"
|
||||
close: "Schließen"
|
||||
import_identity_title: "Identität importieren"
|
||||
import_blurb: "Ersetzt die nostr-Identität dieser Wallet — wähle eine GOBLIN-.backup-Datei oder füge einen nsec ein. Eine Sicherung stellt auch Benutzername und Verlauf wieder her. Sichere zuerst den aktuellen Schlüssel, falls du ihn noch brauchst."
|
||||
import_nsec_hint: "nsec1… oder eingefügte Sicherung"
|
||||
backup_password_hint: "Backup-Passwort (nur wenn anderswo exportiert)"
|
||||
import_btn: "Importieren"
|
||||
importing: "Importiere…"
|
||||
identity_replaced: "Identität ersetzt"
|
||||
now_using: "Jetzt aktiv: %{npub}"
|
||||
import_failed: "Import fehlgeschlagen"
|
||||
name_authority: "Namensautorität"
|
||||
name_authority_title: "Namensautorität ändern"
|
||||
name_authority_blurb: "Der Server, der Namen registriert und verifiziert. Auf eine andere Instanz zeigen, um dort gehostete Namen zu nutzen und zu bezahlen."
|
||||
name_authority_invalid: "Vollständige URL eingeben (https://…)."
|
||||
reset: "Zurücksetzen"
|
||||
save: "Speichern"
|
||||
backup_file: "In Datei sichern"
|
||||
choose_backup_file: "Eine .backup-Datei wählen"
|
||||
backup_read_failed: "Datei konnte nicht gelesen werden."
|
||||
backup_saved: "Sicherung gespeichert"
|
||||
backup_saved_sub: "Bewahre die .backup-Datei sicher auf — wer sie UND dein Passwort hat, kann deine Identität wiederherstellen."
|
||||
backup_file_title: "Identität sichern"
|
||||
backup_file_blurb: "Erstellt eine verschlüsselte .backup-Datei mit Benutzername und Schlüssel. Gib dein Wallet-Passwort ein, um sie zu versiegeln."
|
||||
backup_write_failed: "Datei konnte nicht gespeichert werden."
|
||||
create_backup: "Sicherung erstellen"
|
||||
registered: "%{name} registriert"
|
||||
released_msg: "Freigegeben — der Name ist frei"
|
||||
release_confirm: "%{name} freigeben?"
|
||||
release_blurb: "Sobald er frei ist, ist er verfügbar — jeder kann ihn beanspruchen, auch dein nächster rotierter Schlüssel. Du kannst 10 Minuten lang keinen anderen Benutzernamen registrieren."
|
||||
releasing: "Gebe frei…"
|
||||
keep_it: "Behalten"
|
||||
release_it: "Freigeben"
|
||||
username: "Benutzername"
|
||||
username_note: "Wird als dein Name angezeigt. Öffentlich auf goblin.st. Zahlungen bleiben verschlüsselt."
|
||||
release_username: "Benutzername freigeben"
|
||||
pick_username: "Benutzernamen wählen — optional"
|
||||
working: "Arbeite…"
|
||||
claim: "Sichern"
|
||||
err_just_taken: "Dieser Benutzername wurde gerade vergeben"
|
||||
err_cooldown: "Du hast kürzlich einen Benutzernamen freigegeben — du kannst innerhalb von 10 Minuten einen neuen registrieren."
|
||||
err_unreachable: "goblin.st nicht erreichbar — Verbindungsproblem. Versuche es erneut."
|
||||
err_release: "Freigabe fehlgeschlagen: %{err}"
|
||||
avail_available: "Verfügbar!"
|
||||
avail_taken: "Vergeben"
|
||||
avail_reserved: "Reserviert"
|
||||
avail_invalid: "Namen haben 3–20 Zeichen: a–z, 0–9, _ oder -"
|
||||
avail_quarantined: "Nicht verfügbar"
|
||||
avail_unknown: "Prüfung fehlgeschlagen — Verbindungsproblem. Versuche es erneut."
|
||||
advanced:
|
||||
title: "Erweitert"
|
||||
intro: "Wallet-Werkzeuge auf niedriger Ebene von GRIM. Normalerweise brauchst du diese nicht."
|
||||
own_node_desc: "Synchronisiere einen vollständigen Grin-Node auf diesem Gerät, statt einem öffentlichen zu vertrauen."
|
||||
own_node_active: "Eigener Node läuft"
|
||||
repair: "Wallet reparieren"
|
||||
repair_desc: "Die Kette neu scannen und fehlende Outputs wiederherstellen. Das kann dauern."
|
||||
repair_unavailable: "Benötigt zuerst eine synchronisierte Node-Verbindung."
|
||||
repairing: "Repariere… %{pct}%"
|
||||
restore: "Wallet wiederherstellen"
|
||||
restore_desc: "Lokale Daten löschen und aus deinem Seed neu aufbauen. Nutze das, wenn eine Reparatur nicht half — danach öffnest du die Wallet erneut."
|
||||
restore_confirm: "Zum Wiederherstellen erneut tippen"
|
||||
show_phrase: "Wiederherstellungsphrase"
|
||||
phrase_desc: "Deine 24 grin-Seed-Wörter — der einzige Weg, Guthaben wiederherzustellen. Halte sie offline und privat."
|
||||
reveal: "Phrase anzeigen"
|
||||
hide: "Verbergen"
|
||||
password: "Wallet-Passwort"
|
||||
wrong_password: "Falsches Passwort."
|
||||
delete: "Wallet löschen"
|
||||
delete_desc: "Diese Wallet dauerhaft von diesem Gerät entfernen. Ohne deinen Seed sind Guthaben nicht wiederherstellbar."
|
||||
delete_confirm: "Zum Löschen erneut tippen"
|
||||
manage_node: "Node-Verbindung verwalten"
|
||||
repair_confirm: "Ja, jetzt reparieren"
|
||||
repair_confirm_note: "Die Reparatur scannt die Chain neu und kann einige Minuten dauern."
|
||||
restore_confirm_note: "Dies löscht lokale Daten und baut sie aus deinem Seed neu auf — das kann einige Minuten dauern."
|
||||
nostr_key: "Nostr-Schlüssel"
|
||||
nostr_key_desc: "Dein nsec, der geheime Schlüssel deiner Nostr-Identität. Kopiere ihn oder zeige den QR-Code, um dich bei Nostr-Apps wie magick.market anzumelden. Wer ihn hat, kontrolliert deine Identität, also halte ihn geheim."
|
||||
reveal_nsec: "Schlüssel anzeigen"
|
||||
copy_nsec: "nsec kopieren"
|
||||
show_qr: "QR anzeigen"
|
||||
hide_qr: "QR ausblenden"
|
||||
privacy:
|
||||
title: "Netzwerk-Privatsphäre"
|
||||
intro: "Goblin sendet seinen privaten Datenverkehr über Tor und verbirgt so deine IP vor dem Relay — die Verschlüsselung verbirgt den Rest, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann."
|
||||
payments: "Zahlungen"
|
||||
payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt."
|
||||
usernames: "Benutzernamen"
|
||||
usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st."
|
||||
price_avatars: "Preis"
|
||||
price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen."
|
||||
over_mixnet: "Über Tor"
|
||||
direct_connection: "Direkte Verbindung"
|
||||
grin_node: "Grin-Node"
|
||||
grin_node_blurb: "Block-Synchronisierung und Übertragung deiner Transaktion ins Netzwerk. Dies sind öffentliche Chain-Daten, für alle gleich, und nicht mit deiner Identität verknüpft."
|
||||
pairing:
|
||||
title: "Kopplung"
|
||||
intro: "Womit dein Guthaben und deine Beträge verglichen werden."
|
||||
pair_with: "Koppeln mit"
|
||||
rates_note: "Kurse werden über Tor abgerufen, nur solange eine Kopplung aktiv ist — aus bedeutet, dass keine Kursanfrage dein Gerät verlässt."
|
||||
relays:
|
||||
title: "Relays"
|
||||
intro: "Zahlungsnachrichten werden an jedes Relay unten gespiegelt; ein erreichbares Relay genügt zum Empfangen."
|
||||
your_relays: "Deine Relays"
|
||||
add_relay: "Relay hinzufügen"
|
||||
add_relay_btn: "Relay hinzufügen"
|
||||
save_reconnect: "Speichern & neu verbinden"
|
||||
none: "keine"
|
||||
count: "%{n} Relays"
|
||||
node:
|
||||
title: "Node"
|
||||
connection: "Verbindung"
|
||||
integrated: "Integrierter Node"
|
||||
applies_after: "Wird wirksam, nachdem das Wallet gesperrt und wieder entsperrt wurde."
|
||||
add_external: "Externen Node hinzufügen"
|
||||
api_secret_hint: "API-Secret (optional)"
|
||||
add_node: "Node hinzufügen"
|
||||
integrated_host: "integrierter Node"
|
||||
summary_syncing: "%{conn} · synchronisiere"
|
||||
summary_block: "Block %{height} · %{conn}"
|
||||
nips:
|
||||
title: "nostr & NIPs"
|
||||
intro1: "Goblin spricht nostr — ein offenes Protokoll signierter Nachrichten, die über einfache Relay-Server weitergereicht werden. Dein Wallet trägt seine eigene nostr-Identität: einen eigenständigen Zufallsschlüssel, bewusst unabhängig von deinem Guthaben und Seed gehalten. Jede Zahlung reist als Ende-zu-Ende-verschlüsselte Direktnachricht zwischen Identitäten, mit dem slatepack im Inneren."
|
||||
intro2: "goblin.st ist Goblins Namensdienst: Das Sichern eines Benutzernamens veröffentlicht dort eine Name → Schlüssel-Zuordnung (NIP-05), sodass Leute you statt eines langen npub bezahlen können. Der Benutzername ist öffentlich; Zahlungsinhalte sind es nie. NIPs sind die Bausteine des Protokolls — tippe auf einen, um die Spezifikation zu lesen."
|
||||
n05_title: "Namen"
|
||||
n05_blurb: "Ordnet username@goblin.st deinem Schlüssel zu, sodass Handles wie Adressen funktionieren."
|
||||
n17_title: "Private Nachrichten"
|
||||
n17_blurb: "Die verschlüsselte DM-Hülle, in der jede Zahlung reist."
|
||||
n44_title: "Verschlüsselung"
|
||||
n44_blurb: "Die authentifizierte Chiffre, die in diesen Nachrichten verwendet wird."
|
||||
n49_title: "Schlüsselverschlüsselung"
|
||||
n49_blurb: "Wie der geheime Schlüssel im Ruhezustand gespeichert wird, gesperrt durch dein Passwort."
|
||||
n59_title: "Gift Wrap"
|
||||
n59_blurb: "Verpackt Nachrichten, sodass Relays nicht sehen können, wer mit wem kommuniziert."
|
||||
n98_title: "HTTP-Auth"
|
||||
n98_blurb: "Signiert die Benutzernamen-Registrierungsanfrage an goblin.st."
|
||||
onboarding:
|
||||
intro:
|
||||
private_money_head: "Privates Geld"
|
||||
private_money_body: "Goblin ist ein Wallet für grin — digitales Bargeld ohne Beträge oder Adressen auf seiner Chain."
|
||||
send_like_message_head: "Senden wie eine Nachricht"
|
||||
send_like_message_body: "Zahle an einen username oder npub und es kommt als Ende-zu-Ende-verschlüsselte Nachricht über nostr und Tor an — niemand dazwischen sieht den Betrag oder die Beteiligten."
|
||||
yours_alone_head: "Nur deins"
|
||||
yours_alone_body: "Schlüssel, Namen und Verlauf bleiben auf diesem Gerät. Basiert auf dem GRIM-Wallet."
|
||||
get_started: "Loslegen"
|
||||
footnote: "Dauert etwa eine Minute. Du kannst später alles ändern."
|
||||
node:
|
||||
kicker: "SCHRITT 1 VON 3 · NETZWERK"
|
||||
title: "Wie soll Goblin\ndie Chain beobachten?"
|
||||
own_title: "Eigenen Node betreiben"
|
||||
own_badge: "Privat"
|
||||
own_body: "Vertraut niemandem — dein Wallet prüft die Chain selbst. Synchronisiert im Hintergrund, während du die Einrichtung beendest."
|
||||
connect_title: "Mit einem Node verbinden"
|
||||
connect_badge: "Sofort"
|
||||
connect_body: "Kein Warten auf Sync. Der gewählte Node kann die Abfragen deines Wallets sehen."
|
||||
changeable: "Jederzeit änderbar unter Einstellungen → Node."
|
||||
continue: "Weiter"
|
||||
url_invalid: "Node-URL muss mit http:// oder https:// beginnen"
|
||||
wallet:
|
||||
kicker: "SCHRITT 2 VON 3 · WALLET"
|
||||
title: "Richte dein Wallet ein"
|
||||
create_new: "Neu erstellen"
|
||||
restore_from_seed: "Aus Seed wiederherstellen"
|
||||
name_hint: "Wallet-Name"
|
||||
password_hint: "Passwort"
|
||||
repeat_password_hint: "Passwort wiederholen"
|
||||
restore_hint: "Halte deine Seed-Wörter bereit — du gibst sie als Nächstes ein."
|
||||
create_hint: "Als Nächstes erhältst du 24 Seed-Wörter zum Aufschreiben. Sie sind das Geld — wer sie hat, hält dein Guthaben."
|
||||
continue: "Weiter"
|
||||
passwords_no_match: "Passwörter stimmen nicht überein"
|
||||
words:
|
||||
kicker: "SCHRITT 2 VON 3 · WALLET"
|
||||
title_restore: "Gib deine Seed-Wörter ein"
|
||||
title_create: "Schreibe diese Wörter auf"
|
||||
write_down_hint: "Auf Papier, in Reihenfolge. Wer diese Wörter hat, kann dein Guthaben nehmen; ohne sie bedeutet ein verlorenes Gerät verlorenes Guthaben."
|
||||
paste: "Einfügen"
|
||||
scan_qr: "QR scannen"
|
||||
copy_clipboard: "In Zwischenablage kopieren (vermeiden)"
|
||||
restore_wallet: "Wallet wiederherstellen"
|
||||
wrote_them_down: "Ich habe sie aufgeschrieben"
|
||||
fill_every_word: "Fülle jedes Wort aus — tippe ein Wort an, um es zu bearbeiten, oder füge die Phrase ein."
|
||||
confirm:
|
||||
kicker: "SCHRITT 2 VON 3 · WALLET"
|
||||
title: "Jetzt beweise es"
|
||||
enter_hint: "Gib die soeben aufgeschriebenen Wörter ein. Tippe ein Wort an, um es zu tippen."
|
||||
paste: "Einfügen"
|
||||
create_wallet: "Wallet erstellen"
|
||||
keep_going: "Weiter so — jedes Wort, in Reihenfolge."
|
||||
identity:
|
||||
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
|
||||
title: "Deine Zahlungsidentität"
|
||||
key_being_made: "Schlüssel wird erstellt…"
|
||||
connected_nym: "über Tor verbunden"
|
||||
connecting_nym: "verbinde über Tor…"
|
||||
fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren."
|
||||
clean_slate_blurb: "Lust auf einen Neuanfang? Tausche jederzeit einen brandneuen Schlüssel ein — das neue Du ist nicht mit dem alten verknüpft. Gleiches Wallet, frisches Gesicht."
|
||||
pick_username: "Benutzernamen wählen — optional"
|
||||
username_blurb: "Freunde zahlen an deinen Namen statt an einen langen Schlüssel. Optional — jederzeit beanspruchbar."
|
||||
username_field_hint: "deinname"
|
||||
working: "Arbeite…"
|
||||
claim_username: "Benutzernamen sichern"
|
||||
available_when_connected: "Verfügbar, sobald Tor verbindet — oder überspringen und später sichern."
|
||||
youre: "Du bist %{name}"
|
||||
claimed_title: "%{name} gehört dir"
|
||||
claimed_blurb: "Freunde können dich jetzt per Namen bezahlen. Alles bereit — öffne dein Wallet."
|
||||
open_wallet: "Mein Wallet öffnen"
|
||||
skip_for_now: "Vorerst überspringen"
|
||||
import_existing: "Schon eine Goblin-Identität? Importieren"
|
||||
import_title: "Identität importieren"
|
||||
import_blurb: "Füge deinen nsec ein oder wähle eine .backup-Datei, um deinen vorhandenen Schlüssel und Benutzernamen zu behalten statt diesen neuen."
|
||||
errors:
|
||||
cant_open: "Wallet konnte nicht geöffnet werden: %{err}"
|
||||
cant_create: "Wallet konnte nicht erstellt werden: %{err}"
|
||||
send:
|
||||
scan_to_request: "Zum Anfordern scannen"
|
||||
scan_to_pay: "Zum Zahlen scannen"
|
||||
tab_scan: "Scannen"
|
||||
tab_my_code: "Mein Code"
|
||||
request_from: "Anfordern von"
|
||||
send_to: "Senden an"
|
||||
search_hint: "handle, npub oder Name"
|
||||
suggested: "%{icon} Vorgeschlagen"
|
||||
no_contacts: "Noch keine Kontakte. Finde jemanden über seinen handle."
|
||||
no_profile: "kein Profil"
|
||||
tag_contact: "Kontakt"
|
||||
tag_on_nostr: "auf nostr"
|
||||
searching_nostr: "Durchsuche nostr…"
|
||||
unverified_title: "Unverifizierten Schlüssel bezahlen?"
|
||||
unverified_body: "Für diesen Schlüssel ist kein nostr-Profil veröffentlicht — er könnte brandneu, anonym oder vertippt sein. Prüfe genau, ob es der richtige ist, bevor du sendest."
|
||||
keep_looking: "Weitersuchen"
|
||||
pay_anyway: "Trotzdem zahlen"
|
||||
scan_not_recipient: "Dieser QR ist kein goblin-Empfänger — erwartet wurde ein npub oder handle"
|
||||
scan_prompt: "Halte einen goblin-Code ins Bild, um zu aktivieren"
|
||||
scan_to_pay_me: "Scannen, um mich zu bezahlen"
|
||||
share_btn: "%{icon} Teilen"
|
||||
share_message: "Bezahl mich auf Goblin — %{handle}\n%{link}\nnpub: %{npub}"
|
||||
none_found: "Niemand gefunden für %{label}"
|
||||
enter_recipient: "Gib einen handle, npub oder Namen ein"
|
||||
amount_title: "Betrag"
|
||||
to_name: "An %{name}"
|
||||
not_enough: "Du hast nicht genug grin"
|
||||
max: "Max"
|
||||
note_label: "Notiz"
|
||||
note_hint: "Notiz hinzufügen…"
|
||||
add_note: "Notiz hinzufügen"
|
||||
edit_note: "Notiz bearbeiten"
|
||||
note_cancel: "Abbrechen"
|
||||
note_save: "Speichern"
|
||||
review_btn: "Prüfen"
|
||||
confirm_request: "Anfrage bestätigen"
|
||||
review_title: "Prüfen"
|
||||
requesting_from: "Fordere an von %{name}"
|
||||
youre_sending: "Du sendest %{name}"
|
||||
row_from: "Von"
|
||||
row_to: "An"
|
||||
row_note: "Notiz"
|
||||
row_proof: "Zahlungsnachweis"
|
||||
row_proof_val: "Enthalten"
|
||||
row_proof_shared: "Nachweis geteilt mit"
|
||||
row_they_pay: "Sie zahlen"
|
||||
row_they_pay_val: "Nur wenn sie zustimmen"
|
||||
row_delivery: "Zustellung"
|
||||
row_delivery_val: "NIP-44-verschlüsselt, über Tor"
|
||||
row_network_fee: "Netzwerkgebühr"
|
||||
row_network_fee_val: "Von deinem Guthaben abgezogen"
|
||||
row_privacy: "Privatsphäre"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "Anfrage senden"
|
||||
request_approve_hint: "Sie erhalten eine Anfrage zum Zustimmen"
|
||||
hold_to_send: "Zum Senden halten"
|
||||
lower_amount: "Zurückgehen und Betrag verringern"
|
||||
hold_confirm_hint: "Gedrückt halten zum Bestätigen"
|
||||
requesting: "Fordere an…"
|
||||
sending: "Sende…"
|
||||
they: "Sie"
|
||||
request_blocked: "%{who} nimmt keine Anfragen an. Bitte sie, dir stattdessen grin zu senden."
|
||||
failed_request_title: "Anfrage fehlgeschlagen"
|
||||
failed_send_title: "Senden fehlgeschlagen"
|
||||
failed_request_body: "Die Anfrage konnte nicht zugestellt werden. Bitte sie, dir stattdessen grin zu senden."
|
||||
failed_send_body: "Die Zahlung wurde nicht zugestellt. Dein grin ist sicher — versuche es erneut."
|
||||
try_again_btn: "Erneut versuchen"
|
||||
close_btn: "Schließen"
|
||||
success:
|
||||
requested: "Angefordert"
|
||||
sent: "Gesendet"
|
||||
from: "von"
|
||||
to: "an"
|
||||
subtitle: "%{dir} %{who} · gerade eben"
|
||||
done_btn: "Fertig"
|
||||
receipt_btn: "Beleg"
|
||||
@@ -44,7 +44,7 @@ wallets:
|
||||
await_fin_amount: Awaiting finalization
|
||||
locked_amount: Locked
|
||||
txs_empty: 'To receive funds manually or over transport use %{message} or %{transport} buttons at the bottom of the screen, to change wallet settings press %{settings} button.'
|
||||
title: Wallets
|
||||
title: Goblin
|
||||
create_desc: Create or import existing wallet from saved recovery phrase.
|
||||
add: Add wallet
|
||||
name: 'Name:'
|
||||
@@ -355,4 +355,478 @@ keyboard:
|
||||
m: m
|
||||
m1: ','
|
||||
m2: .
|
||||
m3: /
|
||||
m3: /
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonymous"
|
||||
connected_nym: "Connected over Tor"
|
||||
nym_ready: "Tor ready · relays…"
|
||||
connecting_nym: "Connecting to Tor…"
|
||||
cant_reach_node: "Can't reach node"
|
||||
node_synced: "Node synced"
|
||||
syncing: "Syncing…"
|
||||
balance_updating: "Balance updating…"
|
||||
balance_stale: "Can't reach node · last known balance"
|
||||
fiat_unavailable: "Rate unavailable"
|
||||
listening: "Listening for payments"
|
||||
block: "Block %{height}"
|
||||
waiting_for_chain: "Waiting for chain…"
|
||||
nav_wallet: "Wallet"
|
||||
nav_pay: "Pay"
|
||||
nav_activity: "Activity"
|
||||
nav_receive: "Receive"
|
||||
nav_settings: "Settings"
|
||||
activity: "Activity"
|
||||
news: "News"
|
||||
empty_title: "No activity yet"
|
||||
empty_sub: "Send or receive grin to get started."
|
||||
recent: "Recent"
|
||||
scan_to_pay: "Scan to pay"
|
||||
type_amount: "Type an amount"
|
||||
request: "Request"
|
||||
pay: "Pay"
|
||||
enter_amount: "Enter an amount to pay or request"
|
||||
activity:
|
||||
canceled: "canceled"
|
||||
pending: "pending"
|
||||
earlier: "Earlier"
|
||||
today: "Today"
|
||||
yesterday: "Yesterday"
|
||||
title: "Activity"
|
||||
requests: "Requests"
|
||||
empty_title: "No activity yet"
|
||||
empty_sub: "Your payments will appear here."
|
||||
pending_header: "Pending"
|
||||
receipt:
|
||||
title: "Receipt"
|
||||
not_found: "Transaction not found"
|
||||
for_note: "For %{note}"
|
||||
details: "Transaction details"
|
||||
canceled: "Canceled"
|
||||
expired: "Expired"
|
||||
funds_returned: "Funds returned"
|
||||
complete: "Complete"
|
||||
payment_received: "Payment received"
|
||||
payment_sent: "Payment sent successfully"
|
||||
pending: "Pending"
|
||||
confs: "%{c}/%{r} confirmations"
|
||||
waiting_to_confirm: "Waiting to confirm"
|
||||
paying: "Paying…"
|
||||
you: "You"
|
||||
to: "To"
|
||||
from: "From"
|
||||
nostr: "nostr"
|
||||
fee_none: "None"
|
||||
network_fee: "Network fee"
|
||||
privacy: "Privacy"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Transaction"
|
||||
cancel_request: "Cancel request"
|
||||
cancel_send: "Cancel payment"
|
||||
cancel_send_confirm: "Tap again to cancel — they may still receive it"
|
||||
cancel_send_done: "Payment cancelled — your funds are available again"
|
||||
cancel_send_too_late: "This payment already went through and can't be cancelled"
|
||||
waiting_to_receive: "Waiting for %{name} to receive…"
|
||||
request:
|
||||
title: "%{name} requests"
|
||||
approve: "Approve"
|
||||
decline: "Decline"
|
||||
review_title: "Review request"
|
||||
hold_to_accept: "Hold to accept"
|
||||
hold_accept_hint: "Press and hold to pay this request"
|
||||
receive:
|
||||
title: "Receive"
|
||||
requesting: "Requesting %{amt}%{tsu} — share to get paid"
|
||||
clear_request: "Clear request"
|
||||
share_handle: "Share your handle to get paid"
|
||||
share_npub: "Share your npub to get paid"
|
||||
copied: "Copied"
|
||||
copy_nostr_id: "Copy nostr ID"
|
||||
copy_address: "Copy address"
|
||||
copy_npub: "Copy npub"
|
||||
share_message: "Pay me on Goblin (goblin.st) — %{npub}"
|
||||
privacy_note: "Your username is public. Payment contents stay encrypted over the network."
|
||||
privacy_note_npub: "Your npub is public. Payment contents stay encrypted over the network."
|
||||
profile:
|
||||
title: "Profile"
|
||||
activity: "Activity"
|
||||
no_activity: "No activity with them yet."
|
||||
unblock: "Unblock"
|
||||
block: "Block"
|
||||
blocked_blurb: "Blocked — their payments and requests are dropped."
|
||||
block_blurb: "Blocking drops their incoming payments and requests."
|
||||
settings:
|
||||
title: "Settings"
|
||||
connected_nostr: "Connected to nostr"
|
||||
connecting_relays: "Connecting to relays…"
|
||||
identity: "Identity"
|
||||
copy_npub: "Copy npub (public)"
|
||||
rotate_key: "Rotate nostr key"
|
||||
import_identity: "Import identity (.backup / nsec)"
|
||||
backup_note: "Moving devices? Back up BOTH: your seed phrase (funds) and your identity .backup file (name + key)."
|
||||
wallet: "Wallet"
|
||||
display_unit: "Display unit"
|
||||
relays: "Relays"
|
||||
nostr_relays: "Nostr Relays"
|
||||
node: "Node"
|
||||
integrated_node: "Integrated node settings"
|
||||
node_advanced: "Advanced"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Manual transaction"
|
||||
lock_wallet: "Lock wallet"
|
||||
switch_wallet: "Switch wallet"
|
||||
advanced: "Advanced"
|
||||
privacy: "Privacy"
|
||||
mixnet_routing: "Tor routing"
|
||||
messages_lookups: "Messages & lookups"
|
||||
auto_accept: "Auto-accept"
|
||||
pairing: "Price currency"
|
||||
accept_anyone: "Anyone"
|
||||
accept_contacts: "Contacts only"
|
||||
accept_ask: "Always ask"
|
||||
requests: "Requests"
|
||||
incoming_requests: "Incoming requests"
|
||||
incoming_requests_sub: "Let others request money from you"
|
||||
hide_amounts: "Hide amounts"
|
||||
hide_amounts_sub: "Hide received amounts in notifications"
|
||||
language: "Language"
|
||||
update_available: "Update available"
|
||||
appearance: "Appearance"
|
||||
theme: "Theme"
|
||||
theme_light: "Light"
|
||||
theme_dark: "Dark"
|
||||
theme_yellow: "Yellow"
|
||||
archive: "Archive"
|
||||
export_archive: "Export archive"
|
||||
wipe_history: "Wipe payment history"
|
||||
wipe_history_confirm: "Tap again to wipe — this can't be undone"
|
||||
about: "About"
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
network: "Network"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Third party"
|
||||
grim: "GRIM (upstream wallet)"
|
||||
grin_node: "Grin node"
|
||||
sp_intro: "Advanced — exchange raw slatepacks by hand, the way GRIM does. Use this only when you can't pay or get paid through a username."
|
||||
sp_receive_group: "Receive or finalize"
|
||||
sp_receive_blurb: "Paste a slatepack someone gave you. Goblin receives the payment, pays the invoice, or finalizes and posts it."
|
||||
sp_process: "Process slatepack"
|
||||
sp_paste_first: "Paste a slatepack first."
|
||||
sp_reply_ready: "Reply ready — send it back to the sender."
|
||||
sp_finalizing: "Finalizing and posting to the chain…"
|
||||
sp_create_group: "Create a payment"
|
||||
sp_create_blurb: "Make a slatepack to hand to someone. They receive it, send the reply back, and you finalize it above."
|
||||
sp_amount_hint: "Amount in grin"
|
||||
sp_addr_hint: "Recipient address (optional)"
|
||||
sp_create: "Create slatepack"
|
||||
sp_ready: "Slatepack ready — hand it to the recipient."
|
||||
sp_amount_gt_zero: "Enter an amount greater than zero."
|
||||
sp_to_send: "Slatepack to send"
|
||||
sp_copy: "Copy slatepack"
|
||||
rotate_line1: "• You get a brand-new RANDOM key; the old npub stops receiving. There is no derivation chain between them."
|
||||
rotate_line2: "• The new key is NOT recoverable from your seed — back up the new nsec right after rotating."
|
||||
rotate_line3: "• Your username is RELEASED — claim the same or a new name right after (anyone else can grab it too once it's free)."
|
||||
rotate_line4: "• Payments still in flight to the old key WILL be disrupted — wait for pending payments to finish first."
|
||||
rotate_line5: "• Contacts who saved your npub directly must re-find you — share your new npub or re-claimed username."
|
||||
cancel: "Cancel"
|
||||
continue: "Continue"
|
||||
final_confirmation: "Final confirmation"
|
||||
rotate_confirm_blurb: "This cannot be undone from the app. Type RESET and enter your wallet password to rotate."
|
||||
type_reset: "Type RESET"
|
||||
wallet_password: "Wallet password"
|
||||
rotate_key_btn: "Rotate key"
|
||||
rotating_key: "Rotating key…"
|
||||
key_rotated: "Key rotated"
|
||||
new_npub: "New npub: %{npub}"
|
||||
backup_new_key: "Back up the NEW secret key now — your seed cannot recover it."
|
||||
copy_new_nsec: "Copy new nsec backup"
|
||||
done: "Done"
|
||||
rotation_failed: "Rotation failed"
|
||||
close: "Close"
|
||||
import_identity_title: "Import identity"
|
||||
import_blurb: "Replaces this wallet's nostr identity — choose a GOBLIN .backup file, or paste a bare nsec. A backup also restores your username and history. Back up the current key first if you still need it."
|
||||
import_nsec_hint: "nsec1… or pasted backup"
|
||||
backup_password_hint: "Backup password (only if exported elsewhere)"
|
||||
import_btn: "Import"
|
||||
importing: "Importing…"
|
||||
identity_replaced: "Identity replaced"
|
||||
now_using: "Now using: %{npub}"
|
||||
import_failed: "Import failed"
|
||||
name_authority: "Name authority"
|
||||
name_authority_title: "Change name authority"
|
||||
name_authority_blurb: "The server that registers and verifies names. Point it at another instance to use and pay names hosted there."
|
||||
name_authority_invalid: "Enter a full URL (https://…)."
|
||||
reset: "Reset"
|
||||
save: "Save"
|
||||
backup_file: "Back up to a file"
|
||||
choose_backup_file: "Choose a .backup file"
|
||||
backup_read_failed: "Couldn't read that file."
|
||||
backup_saved: "Backup saved"
|
||||
backup_saved_sub: "Keep the .backup file safe — anyone with it AND your password can restore your identity."
|
||||
backup_file_title: "Back up identity"
|
||||
backup_file_blurb: "Creates one encrypted .backup file with your username and key. Enter your wallet password to seal it."
|
||||
backup_write_failed: "Couldn't save the file."
|
||||
create_backup: "Create backup"
|
||||
registered: "Registered %{name}"
|
||||
released_msg: "Released — the name is up for grabs"
|
||||
release_confirm: "Release %{name}?"
|
||||
release_blurb: "It's up for grabs the moment it's free — anyone can claim it, including the next key you rotate to. You won't be able to register another username for 10 minutes."
|
||||
releasing: "Releasing…"
|
||||
keep_it: "Keep it"
|
||||
release_it: "Release it"
|
||||
username: "Username"
|
||||
username_note: "Shown as your name. Public on goblin.st. Payments stay encrypted."
|
||||
release_username: "Release username"
|
||||
pick_username: "Pick a username — optional"
|
||||
working: "Working…"
|
||||
claim: "Claim"
|
||||
err_just_taken: "That username was just taken"
|
||||
err_cooldown: "You recently released a username — you can register a new one within 10 minutes."
|
||||
err_unreachable: "Couldn't reach goblin.st — connection hiccup. Try again."
|
||||
err_release: "Couldn't release: %{err}"
|
||||
avail_available: "Available!"
|
||||
avail_taken: "Taken"
|
||||
avail_reserved: "Reserved"
|
||||
avail_invalid: "Names are 3–20 chars: a–z, 0–9, _ or -"
|
||||
avail_quarantined: "Not available"
|
||||
avail_unknown: "Couldn't check — connection hiccup. Try again."
|
||||
advanced:
|
||||
title: "Advanced"
|
||||
intro: "Low-level wallet tools from GRIM. You won't normally need these."
|
||||
own_node_desc: "Sync a full Grin node on this device instead of trusting a public one."
|
||||
own_node_active: "Running your own node"
|
||||
repair: "Repair wallet"
|
||||
repair_desc: "Re-scan the chain and restore any missing outputs. This can take a while."
|
||||
repair_unavailable: "Needs a synced node connection first."
|
||||
repairing: "Repairing… %{pct}%"
|
||||
restore: "Restore wallet"
|
||||
restore_desc: "Delete local data and rebuild from your seed. Use this if a repair didn't help — you'll re-open the wallet after."
|
||||
restore_confirm: "Tap again to restore"
|
||||
show_phrase: "Recovery phrase"
|
||||
phrase_desc: "Your 24 grin seed words — the only way to recover funds. Keep them offline and private."
|
||||
reveal: "Show phrase"
|
||||
hide: "Hide"
|
||||
password: "Wallet password"
|
||||
wrong_password: "Wrong password."
|
||||
delete: "Delete wallet"
|
||||
delete_desc: "Permanently remove this wallet from this device. Without your seed, funds can't be recovered."
|
||||
delete_confirm: "Tap again to delete"
|
||||
manage_node: "Manage node connection"
|
||||
repair_confirm: "Yes, repair now"
|
||||
repair_confirm_note: "Repair re-scans the chain and can take a few minutes."
|
||||
restore_confirm_note: "This erases local data and rebuilds it from your seed — it can take several minutes."
|
||||
nostr_key: "Nostr key"
|
||||
nostr_key_desc: "Your nsec, the secret key to your nostr identity. Copy it or show its QR to log in to nostr apps like magick.market. Anyone who has it controls your identity, so keep it private."
|
||||
reveal_nsec: "Show key"
|
||||
copy_nsec: "Copy nsec"
|
||||
show_qr: "Show QR"
|
||||
hide_qr: "Hide QR"
|
||||
privacy:
|
||||
title: "Network privacy"
|
||||
intro: "Goblin sends its private traffic through Tor, which hides your IP from the relay — encryption hides the rest, so a relay can't link a payment back to you."
|
||||
payments: "Payments"
|
||||
payments_blurb: "Every nostr message carrying a slatepack."
|
||||
usernames: "usernames"
|
||||
usernames_blurb: "NIP-05 name lookups to and from goblin.st."
|
||||
price_avatars: "Price"
|
||||
price_avatars_blurb: "The live fiat rate shown next to amounts."
|
||||
over_mixnet: "Over Tor"
|
||||
direct_connection: "Direct connection"
|
||||
grin_node: "Grin node"
|
||||
grin_node_blurb: "Block sync and broadcasting your transaction to the network. This is public chain data, the same for everyone, and isn't linked to your identity."
|
||||
pairing:
|
||||
title: "Pairing"
|
||||
intro: "What your balance and amounts are shown against."
|
||||
pair_with: "Pair with"
|
||||
rates_note: "Rates fetch over Tor, only while a pairing is on — off means no rate request leaves your device."
|
||||
relays:
|
||||
title: "Relays"
|
||||
intro: "Payment messages are mirrored to every relay below; one reachable relay is enough to receive."
|
||||
your_relays: "Your relays"
|
||||
add_relay: "Add relay"
|
||||
add_relay_btn: "Add relay"
|
||||
save_reconnect: "Save & reconnect"
|
||||
none: "none"
|
||||
count: "%{n} relays"
|
||||
node:
|
||||
title: "Node"
|
||||
connection: "Connection"
|
||||
integrated: "Integrated node"
|
||||
applies_after: "Applies after the wallet is locked and unlocked again."
|
||||
add_external: "Add external node"
|
||||
api_secret_hint: "API secret (optional)"
|
||||
add_node: "Add node"
|
||||
integrated_host: "integrated node"
|
||||
summary_syncing: "%{conn} · syncing"
|
||||
summary_block: "Block %{height} · %{conn}"
|
||||
nips:
|
||||
title: "nostr & NIPs"
|
||||
intro1: "Goblin speaks nostr — an open protocol of signed messages passed through simple relay servers. Your wallet carries its own nostr identity: a standalone random key, kept deliberately independent of your funds and seed. Every payment travels as an end-to-end encrypted direct message between identities, with the slatepack riding inside."
|
||||
intro2: "goblin.st is Goblin's name service: claiming a username publishes a name → key mapping there (NIP-05), so people can pay you instead of a long npub. The username is public; payment contents never are. NIPs are the protocol's building blocks — tap one to read the spec."
|
||||
n05_title: "Names"
|
||||
n05_blurb: "Maps username@goblin.st to your key, so handles work like addresses."
|
||||
n17_title: "Private messages"
|
||||
n17_blurb: "The encrypted DM envelope every payment travels in."
|
||||
n44_title: "Encryption"
|
||||
n44_blurb: "The authenticated cipher used inside those messages."
|
||||
n49_title: "Key encryption"
|
||||
n49_blurb: "How the secret key is stored at rest, locked by your password."
|
||||
n59_title: "Gift wrap"
|
||||
n59_blurb: "Wraps messages so relays can't see who is talking to whom."
|
||||
n98_title: "HTTP auth"
|
||||
n98_blurb: "Signs the username registration request to goblin.st."
|
||||
onboarding:
|
||||
intro:
|
||||
private_money_head: "Private money"
|
||||
private_money_body: "Goblin is a wallet for grin — digital cash with no amounts or addresses on its chain."
|
||||
send_like_message_head: "Send like a message"
|
||||
send_like_message_body: "Pay a username or npub and it arrives as an end-to-end encrypted message over nostr and Tor — no one in between can see the amount or who's involved."
|
||||
yours_alone_head: "Yours alone"
|
||||
yours_alone_body: "Keys, names and history live on this device. Built on the GRIM wallet."
|
||||
get_started: "Get started"
|
||||
footnote: "Takes about a minute. You can change everything later."
|
||||
node:
|
||||
kicker: "STEP 1 OF 3 · NETWORK"
|
||||
title: "How should Goblin\nwatch the chain?"
|
||||
own_title: "Run my own node"
|
||||
own_badge: "Private"
|
||||
own_body: "Trusts no one — your wallet checks the chain itself. Syncs in the background while you finish setup."
|
||||
connect_title: "Connect to a node"
|
||||
connect_badge: "Instant"
|
||||
connect_body: "No sync wait. The node you pick can see your wallet's queries."
|
||||
changeable: "Changeable any time in Settings → Node."
|
||||
continue: "Continue"
|
||||
url_invalid: "Node URL must start with http:// or https://"
|
||||
wallet:
|
||||
kicker: "STEP 2 OF 3 · WALLET"
|
||||
title: "Set up your wallet"
|
||||
create_new: "Create new"
|
||||
restore_from_seed: "Restore from seed"
|
||||
name_hint: "Wallet name"
|
||||
password_hint: "Password"
|
||||
repeat_password_hint: "Repeat password"
|
||||
restore_hint: "Have your seed words ready — you'll enter them next."
|
||||
create_hint: "Next you'll get 24 seed words to write down. They are the money — anyone holding them holds your funds."
|
||||
continue: "Continue"
|
||||
passwords_no_match: "Passwords don't match"
|
||||
words:
|
||||
kicker: "STEP 2 OF 3 · WALLET"
|
||||
title_restore: "Enter your seed words"
|
||||
title_create: "Write these words down"
|
||||
write_down_hint: "On paper, in order. Anyone with these words can take your funds; without them a lost device means lost funds."
|
||||
paste: "Paste"
|
||||
scan_qr: "Scan QR"
|
||||
copy_clipboard: "Copy to clipboard (avoid this)"
|
||||
restore_wallet: "Restore wallet"
|
||||
wrote_them_down: "I wrote them down"
|
||||
fill_every_word: "Fill every word — tap a word to edit it, or paste the phrase."
|
||||
confirm:
|
||||
kicker: "STEP 2 OF 3 · WALLET"
|
||||
title: "Now prove it"
|
||||
enter_hint: "Enter the words you just wrote down. Tap a word to type it."
|
||||
paste: "Paste"
|
||||
create_wallet: "Create wallet"
|
||||
keep_going: "Keep going — every word, in order."
|
||||
identity:
|
||||
kicker: "STEP 3 OF 3 · IDENTITY"
|
||||
title: "Your payment identity"
|
||||
key_being_made: "key being made…"
|
||||
connected_nym: "connected over Tor"
|
||||
connecting_nym: "connecting over Tor…"
|
||||
fresh_key_blurb: "A payment key that isn't part of your seed — rotate it anytime to stay private, without touching your funds."
|
||||
clean_slate_blurb: "Want a clean slate? Swap in a brand-new key any time — the new you isn't linked to the old one. Same wallet, fresh face."
|
||||
pick_username: "Pick a username — optional"
|
||||
username_blurb: "Friends pay your name instead of a long key. Optional — claim one any time."
|
||||
username_field_hint: "yourname"
|
||||
working: "Working…"
|
||||
claim_username: "Claim username"
|
||||
available_when_connected: "Available once Tor connects — or skip and claim later."
|
||||
youre: "You're %{name}"
|
||||
claimed_title: "%{name} is yours"
|
||||
claimed_blurb: "Friends can now pay you by name. You're all set — open your wallet."
|
||||
open_wallet: "Open my wallet"
|
||||
skip_for_now: "Skip for now"
|
||||
import_existing: "Already have a Goblin identity? Import it"
|
||||
import_title: "Import your identity"
|
||||
import_blurb: "Paste your nsec or pick a .backup file to keep your existing key and username instead of this new one."
|
||||
errors:
|
||||
cant_open: "Couldn't open the wallet: %{err}"
|
||||
cant_create: "Couldn't create the wallet: %{err}"
|
||||
send:
|
||||
scan_to_request: "Scan to request"
|
||||
scan_to_pay: "Scan to pay"
|
||||
tab_scan: "Scan"
|
||||
tab_my_code: "My Code"
|
||||
request_from: "Request from"
|
||||
send_to: "Send to"
|
||||
search_hint: "handle, npub, or name"
|
||||
suggested: "%{icon} Suggested"
|
||||
no_contacts: "No contacts yet. Find someone by their handle."
|
||||
no_profile: "no profile"
|
||||
tag_contact: "contact"
|
||||
tag_on_nostr: "on nostr"
|
||||
searching_nostr: "Searching nostr…"
|
||||
unverified_title: "Pay an unverified key?"
|
||||
unverified_body: "No nostr profile is published for this key — it may be brand new, anonymous, or mistyped. Double-check it's the right one before sending."
|
||||
keep_looking: "Keep looking"
|
||||
pay_anyway: "Pay anyway"
|
||||
scan_not_recipient: "That QR isn't a goblin recipient — expected an npub or handle"
|
||||
scan_prompt: "Position a goblin code in view to activate"
|
||||
scan_to_pay_me: "Scan to pay me"
|
||||
share_btn: "%{icon} Share"
|
||||
share_message: "Pay me on Goblin — %{handle}\n%{link}\nnpub: %{npub}"
|
||||
none_found: "No one found for %{label}"
|
||||
enter_recipient: "Enter a handle, npub, or name"
|
||||
amount_title: "Amount"
|
||||
to_name: "To %{name}"
|
||||
not_enough: "You don't have enough grin"
|
||||
max: "Max"
|
||||
note_label: "Note"
|
||||
note_hint: "Add a note…"
|
||||
add_note: "Add a note"
|
||||
edit_note: "Edit note"
|
||||
note_cancel: "Cancel"
|
||||
note_save: "Save"
|
||||
review_btn: "Review"
|
||||
confirm_request: "Confirm request"
|
||||
review_title: "Review"
|
||||
requesting_from: "Requesting from %{name}"
|
||||
youre_sending: "You're sending %{name}"
|
||||
row_from: "From"
|
||||
row_to: "To"
|
||||
row_note: "Note"
|
||||
row_proof: "Payment proof"
|
||||
row_proof_val: "Included"
|
||||
row_proof_shared: "Proof shared with"
|
||||
row_they_pay: "They pay"
|
||||
row_they_pay_val: "Only if they approve"
|
||||
row_delivery: "Delivery"
|
||||
row_delivery_val: "NIP-44 encrypted, over Tor"
|
||||
row_network_fee: "Network fee"
|
||||
row_network_fee_val: "Deducted from your balance"
|
||||
row_privacy: "Privacy"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "Send request"
|
||||
request_approve_hint: "They'll get a request to approve"
|
||||
hold_to_send: "Hold to send"
|
||||
lower_amount: "Go back and lower the amount"
|
||||
hold_confirm_hint: "Press and hold to confirm"
|
||||
requesting: "Requesting…"
|
||||
sending: "Sending…"
|
||||
they: "They"
|
||||
request_blocked: "%{who} isn't accepting requests. Ask them to send you grin instead."
|
||||
failed_request_title: "Couldn't request"
|
||||
failed_send_title: "Couldn't send"
|
||||
failed_request_body: "We couldn't deliver the request. Ask them to send you grin instead."
|
||||
failed_send_body: "The payment wasn't delivered. Your grin is safe — try again."
|
||||
try_again_btn: "Try again"
|
||||
close_btn: "Close"
|
||||
success:
|
||||
requested: "Requested"
|
||||
sent: "Sent"
|
||||
from: "from"
|
||||
to: "to"
|
||||
subtitle: "%{dir} %{who} · just now"
|
||||
done_btn: "Done"
|
||||
receipt_btn: "Receipt"
|
||||
@@ -44,7 +44,7 @@ wallets:
|
||||
await_fin_amount: En attente de finalisation
|
||||
locked_amount: Verrouillé
|
||||
txs_empty: "Pour recevoir des fonds manuellement ou par transport, utilisez les boutons %{message} ou %{transport} en bas de l'écran. Pour modifier les paramètres du portefeuille, appuyez sur le bouton %{settings}."
|
||||
title: Portefeuilles
|
||||
title: Goblin
|
||||
create_desc: Créer ou importer un portefeuille existant à partir de la phrase de récupération sauvegardée.
|
||||
add: Ajouter un portefeuille
|
||||
name: 'Nom:'
|
||||
@@ -355,4 +355,478 @@ keyboard:
|
||||
m: ','
|
||||
m1: .
|
||||
m2: ':'
|
||||
m3: /
|
||||
m3: /
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonyme"
|
||||
connected_nym: "Connecté via Tor"
|
||||
nym_ready: "Tor prêt · relais…"
|
||||
connecting_nym: "Connexion à Tor…"
|
||||
cant_reach_node: "Nœud injoignable"
|
||||
node_synced: "Nœud synchronisé"
|
||||
syncing: "Synchronisation…"
|
||||
balance_updating: "Solde en cours de mise à jour…"
|
||||
balance_stale: "Nœud injoignable · dernier solde connu"
|
||||
fiat_unavailable: "Taux indisponible"
|
||||
listening: "En attente de paiements"
|
||||
block: "Bloc %{height}"
|
||||
waiting_for_chain: "En attente de la chaîne…"
|
||||
nav_wallet: "Portefeuille"
|
||||
nav_pay: "Payer"
|
||||
nav_activity: "Activité"
|
||||
nav_receive: "Recevoir"
|
||||
nav_settings: "Réglages"
|
||||
activity: "Activité"
|
||||
news: "Actualités"
|
||||
empty_title: "Aucune activité"
|
||||
empty_sub: "Envoyez ou recevez des grin pour commencer."
|
||||
recent: "Récent"
|
||||
scan_to_pay: "Scanner pour payer"
|
||||
type_amount: "Saisir un montant"
|
||||
request: "Demander"
|
||||
pay: "Payer"
|
||||
enter_amount: "Saisissez un montant à payer ou demander"
|
||||
activity:
|
||||
canceled: "annulé"
|
||||
pending: "en attente"
|
||||
earlier: "Plus tôt"
|
||||
today: "Aujourd'hui"
|
||||
yesterday: "Hier"
|
||||
title: "Activité"
|
||||
requests: "Demandes"
|
||||
empty_title: "Aucune activité"
|
||||
empty_sub: "Vos paiements apparaîtront ici."
|
||||
pending_header: "En attente"
|
||||
receipt:
|
||||
title: "Reçu"
|
||||
not_found: "Transaction introuvable"
|
||||
for_note: "Pour %{note}"
|
||||
details: "Détails de la transaction"
|
||||
canceled: "Annulé"
|
||||
expired: "Expiré"
|
||||
funds_returned: "Fonds retournés"
|
||||
complete: "Terminé"
|
||||
payment_received: "Paiement reçu"
|
||||
payment_sent: "Paiement envoyé avec succès"
|
||||
pending: "En attente"
|
||||
confs: "%{c}/%{r} confirmations"
|
||||
waiting_to_confirm: "En attente de confirmation"
|
||||
paying: "Paiement…"
|
||||
you: "Vous"
|
||||
to: "À"
|
||||
from: "De"
|
||||
nostr: "nostr"
|
||||
fee_none: "Aucun"
|
||||
network_fee: "Frais de réseau"
|
||||
privacy: "Confidentialité"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Transaction"
|
||||
cancel_request: "Annuler la demande"
|
||||
cancel_send: "Annuler le paiement"
|
||||
cancel_send_confirm: "Appuyez à nouveau pour annuler — il peut encore le recevoir"
|
||||
cancel_send_done: "Paiement annulé — vos fonds sont à nouveau disponibles"
|
||||
cancel_send_too_late: "Ce paiement est déjà passé et ne peut pas être annulé"
|
||||
waiting_to_receive: "En attente de réception par %{name}…"
|
||||
request:
|
||||
title: "%{name} demande"
|
||||
approve: "Approuver"
|
||||
decline: "Refuser"
|
||||
review_title: "Vérifier la demande"
|
||||
hold_to_accept: "Maintenir pour accepter"
|
||||
hold_accept_hint: "Maintenez pour payer cette demande"
|
||||
receive:
|
||||
title: "Recevoir"
|
||||
requesting: "Demande de %{amt}%{tsu} — partagez pour être payé"
|
||||
clear_request: "Effacer la demande"
|
||||
share_handle: "Partagez votre identifiant pour être payé"
|
||||
share_npub: "Partagez votre npub pour être payé"
|
||||
copied: "Copié"
|
||||
copy_nostr_id: "Copier l'ID nostr"
|
||||
copy_address: "Copier l'adresse"
|
||||
copy_npub: "Copier npub"
|
||||
share_message: "Payez-moi sur Goblin (goblin.st) — %{npub}"
|
||||
privacy_note: "Votre nom d'utilisateur est public. Le contenu des paiements reste chiffré sur le réseau."
|
||||
privacy_note_npub: "Votre npub est public. Le contenu des paiements reste chiffré sur le réseau."
|
||||
profile:
|
||||
title: "Profil"
|
||||
activity: "Activité"
|
||||
no_activity: "Aucune activité avec cette personne."
|
||||
unblock: "Débloquer"
|
||||
block: "Bloquer"
|
||||
blocked_blurb: "Bloqué — ses paiements et demandes sont ignorés."
|
||||
block_blurb: "Le blocage ignore ses paiements et demandes entrants."
|
||||
settings:
|
||||
title: "Réglages"
|
||||
connected_nostr: "Connecté à nostr"
|
||||
connecting_relays: "Connexion aux relais…"
|
||||
identity: "Identité"
|
||||
copy_npub: "Copier le npub (public)"
|
||||
rotate_key: "Renouveler la clé nostr"
|
||||
import_identity: "Importer l'identité (.backup / nsec)"
|
||||
backup_note: "Changement d'appareil ? Sauvegardez les DEUX : votre phrase seed (fonds) et votre fichier .backup d'identité (nom + clé)."
|
||||
wallet: "Portefeuille"
|
||||
display_unit: "Unité d'affichage"
|
||||
relays: "Relais"
|
||||
nostr_relays: "Relais Nostr"
|
||||
node: "Nœud"
|
||||
integrated_node: "Paramètres du nœud intégré"
|
||||
node_advanced: "Avancé"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Transaction manuelle"
|
||||
lock_wallet: "Verrouiller le portefeuille"
|
||||
switch_wallet: "Changer de portefeuille"
|
||||
advanced: "Avancé"
|
||||
privacy: "Confidentialité"
|
||||
mixnet_routing: "Routage par Tor"
|
||||
messages_lookups: "Messages et recherches"
|
||||
auto_accept: "Acceptation auto"
|
||||
pairing: "Devise des prix"
|
||||
accept_anyone: "Tout le monde"
|
||||
accept_contacts: "Contacts seulement"
|
||||
accept_ask: "Toujours demander"
|
||||
requests: "Demandes"
|
||||
incoming_requests: "Demandes entrantes"
|
||||
incoming_requests_sub: "Laisser les autres vous demander de l'argent"
|
||||
hide_amounts: "Masquer les montants"
|
||||
hide_amounts_sub: "Masquer les montants reçus dans les notifications"
|
||||
language: "Langue"
|
||||
update_available: "Mise à jour disponible"
|
||||
appearance: "Apparence"
|
||||
theme: "Thème"
|
||||
theme_light: "Clair"
|
||||
theme_dark: "Sombre"
|
||||
theme_yellow: "Jaune"
|
||||
archive: "Archive"
|
||||
export_archive: "Exporter l'archive"
|
||||
wipe_history: "Effacer l'historique des paiements"
|
||||
wipe_history_confirm: "Appuyez à nouveau pour effacer — action irréversible"
|
||||
about: "À propos"
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
network: "Réseau"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Tiers"
|
||||
grim: "GRIM (portefeuille amont)"
|
||||
grin_node: "Nœud grin"
|
||||
sp_intro: "Avancé — échangez des slatepacks bruts à la main, comme le fait GRIM. À utiliser seulement si vous ne pouvez pas payer ou être payé via un username."
|
||||
sp_receive_group: "Recevoir ou finaliser"
|
||||
sp_receive_blurb: "Collez un slatepack qu'on vous a donné. Goblin reçoit le paiement, règle la facture, ou le finalise et le publie."
|
||||
sp_process: "Traiter le slatepack"
|
||||
sp_paste_first: "Collez d'abord un slatepack."
|
||||
sp_reply_ready: "Réponse prête — renvoyez-la à l'expéditeur."
|
||||
sp_finalizing: "Finalisation et publication sur la chaîne…"
|
||||
sp_create_group: "Créer un paiement"
|
||||
sp_create_blurb: "Créez un slatepack à remettre à quelqu'un. Il le reçoit, vous renvoie la réponse, et vous le finalisez ci-dessus."
|
||||
sp_amount_hint: "Montant en grin"
|
||||
sp_addr_hint: "Adresse du destinataire (facultatif)"
|
||||
sp_create: "Créer un slatepack"
|
||||
sp_ready: "Slatepack prêt — remettez-le au destinataire."
|
||||
sp_amount_gt_zero: "Saisissez un montant supérieur à zéro."
|
||||
sp_to_send: "Slatepack à envoyer"
|
||||
sp_copy: "Copier le slatepack"
|
||||
rotate_line1: "• Vous obtenez une toute nouvelle clé ALÉATOIRE ; l'ancien npub cesse de recevoir. Il n'y a aucune chaîne de dérivation entre eux."
|
||||
rotate_line2: "• La nouvelle clé n'est PAS récupérable depuis votre phrase de récupération — sauvegardez le nouveau nsec juste après le renouvellement."
|
||||
rotate_line3: "• Votre nom d'utilisateur est LIBÉRÉ — réclamez le même ou un nouveau juste après (une fois libre, n'importe qui peut le prendre)."
|
||||
rotate_line4: "• Les paiements encore en cours vers l'ancienne clé SERONT interrompus — attendez d'abord la fin des paiements en attente."
|
||||
rotate_line5: "• Les contacts qui ont enregistré votre npub directement doivent vous retrouver — partagez votre nouveau npub ou votre username re-réservé."
|
||||
cancel: "Annuler"
|
||||
continue: "Continuer"
|
||||
final_confirmation: "Confirmation finale"
|
||||
rotate_confirm_blurb: "Cette action est irréversible depuis l'app. Tapez RESET et saisissez le mot de passe du portefeuille pour renouveler."
|
||||
type_reset: "Tapez RESET"
|
||||
wallet_password: "Mot de passe du portefeuille"
|
||||
rotate_key_btn: "Renouveler la clé"
|
||||
rotating_key: "Renouvellement de la clé…"
|
||||
key_rotated: "Clé renouvelée"
|
||||
new_npub: "Nouveau npub : %{npub}"
|
||||
backup_new_key: "Sauvegardez la NOUVELLE clé secrète maintenant — votre phrase de récupération ne peut pas la restaurer."
|
||||
copy_new_nsec: "Copier la sauvegarde du nouveau nsec"
|
||||
done: "Terminé"
|
||||
rotation_failed: "Échec du renouvellement"
|
||||
close: "Fermer"
|
||||
import_identity_title: "Importer une identité"
|
||||
import_blurb: "Remplace l'identité nostr de ce portefeuille — choisissez un fichier .backup GOBLIN, ou collez un nsec. Une sauvegarde restaure aussi votre nom d'utilisateur et votre historique. Sauvegardez d'abord la clé actuelle si besoin."
|
||||
import_nsec_hint: "nsec1… ou sauvegarde collée"
|
||||
backup_password_hint: "Mot de passe de sauvegarde (uniquement si exportée ailleurs)"
|
||||
import_btn: "Importer"
|
||||
importing: "Importation…"
|
||||
identity_replaced: "Identité remplacée"
|
||||
now_using: "Utilise maintenant : %{npub}"
|
||||
import_failed: "Échec de l'importation"
|
||||
name_authority: "Autorité de noms"
|
||||
name_authority_title: "Changer l'autorité de noms"
|
||||
name_authority_blurb: "Le serveur qui enregistre et vérifie les noms. Pointez-le vers une autre instance pour utiliser et payer des noms qui y sont hébergés."
|
||||
name_authority_invalid: "Saisissez une URL complète (https://…)."
|
||||
reset: "Réinitialiser"
|
||||
save: "Enregistrer"
|
||||
backup_file: "Sauvegarder dans un fichier"
|
||||
choose_backup_file: "Choisir un fichier .backup"
|
||||
backup_read_failed: "Impossible de lire ce fichier."
|
||||
backup_saved: "Sauvegarde enregistrée"
|
||||
backup_saved_sub: "Conservez le fichier .backup en lieu sûr — quiconque l'a AVEC votre mot de passe peut restaurer votre identité."
|
||||
backup_file_title: "Sauvegarder l'identité"
|
||||
backup_file_blurb: "Crée un fichier .backup chiffré avec votre nom d'utilisateur et votre clé. Saisissez le mot de passe du portefeuille pour le sceller."
|
||||
backup_write_failed: "Impossible d'enregistrer le fichier."
|
||||
create_backup: "Créer la sauvegarde"
|
||||
registered: "%{name} enregistré"
|
||||
released_msg: "Libéré — le nom est disponible"
|
||||
release_confirm: "Libérer %{name} ?"
|
||||
release_blurb: "Dès qu'il est libre, il est disponible — n'importe qui peut le réclamer, y compris votre prochaine clé. Vous ne pourrez pas enregistrer un autre nom d'utilisateur pendant 10 minutes."
|
||||
releasing: "Libération…"
|
||||
keep_it: "Le garder"
|
||||
release_it: "Le libérer"
|
||||
username: "Nom d'utilisateur"
|
||||
username_note: "Affiché comme votre nom. Public sur goblin.st. Les paiements restent chiffrés."
|
||||
release_username: "Libérer le nom d'utilisateur"
|
||||
pick_username: "Choisir un nom d'utilisateur — facultatif"
|
||||
working: "En cours…"
|
||||
claim: "Réserver"
|
||||
err_just_taken: "Ce nom d'utilisateur vient d'être pris"
|
||||
err_cooldown: "Vous avez récemment libéré un nom d'utilisateur — vous pouvez en enregistrer un nouveau dans les 10 minutes."
|
||||
err_unreachable: "Impossible de joindre goblin.st — souci de connexion. Réessayez."
|
||||
err_release: "Impossible de libérer : %{err}"
|
||||
avail_available: "Disponible !"
|
||||
avail_taken: "Pris"
|
||||
avail_reserved: "Réservé"
|
||||
avail_invalid: "Les noms font 3 à 20 caractères : a–z, 0–9, _ ou -"
|
||||
avail_quarantined: "Indisponible"
|
||||
avail_unknown: "Vérification impossible — souci de connexion. Réessayez."
|
||||
advanced:
|
||||
title: "Avancé"
|
||||
intro: "Outils de portefeuille bas niveau de GRIM. Vous n'en aurez normalement pas besoin."
|
||||
own_node_desc: "Synchronisez un nœud Grin complet sur cet appareil au lieu de faire confiance à un nœud public."
|
||||
own_node_active: "Votre propre nœud est actif"
|
||||
repair: "Réparer le portefeuille"
|
||||
repair_desc: "Re-scanner la chaîne et restaurer les sorties manquantes. Cela peut prendre du temps."
|
||||
repair_unavailable: "Nécessite d'abord une connexion à un nœud synchronisé."
|
||||
repairing: "Réparation… %{pct}%"
|
||||
restore: "Restaurer le portefeuille"
|
||||
restore_desc: "Supprimer les données locales et reconstruire depuis votre seed. À utiliser si une réparation n'a pas aidé — vous rouvrirez le portefeuille ensuite."
|
||||
restore_confirm: "Touchez à nouveau pour restaurer"
|
||||
show_phrase: "Phrase de récupération"
|
||||
phrase_desc: "Vos 24 mots de seed grin — le seul moyen de récupérer les fonds. Gardez-les hors ligne et privés."
|
||||
reveal: "Afficher la phrase"
|
||||
hide: "Masquer"
|
||||
password: "Mot de passe du portefeuille"
|
||||
wrong_password: "Mot de passe incorrect."
|
||||
delete: "Supprimer le portefeuille"
|
||||
delete_desc: "Supprimer définitivement ce portefeuille de cet appareil. Sans votre seed, les fonds sont irrécupérables."
|
||||
delete_confirm: "Touchez à nouveau pour supprimer"
|
||||
manage_node: "Gérer la connexion au nœud"
|
||||
repair_confirm: "Oui, réparer maintenant"
|
||||
repair_confirm_note: "La réparation réanalyse la chaîne et peut prendre quelques minutes."
|
||||
restore_confirm_note: "Cela efface les données locales et les reconstruit depuis votre seed — cela peut prendre plusieurs minutes."
|
||||
nostr_key: "Clé Nostr"
|
||||
nostr_key_desc: "Votre nsec, la clé secrète de votre identité Nostr. Copiez-la ou affichez son QR pour vous connecter à des applis Nostr comme magick.market. Quiconque la possède contrôle votre identité, gardez-la privée."
|
||||
reveal_nsec: "Afficher la clé"
|
||||
copy_nsec: "Copier le nsec"
|
||||
show_qr: "Afficher le QR"
|
||||
hide_qr: "Masquer le QR"
|
||||
privacy:
|
||||
title: "Confidentialité réseau"
|
||||
intro: "Goblin envoie son trafic privé via Tor, qui masque votre IP au relais — le chiffrement masque le reste, afin qu'un relais ne puisse pas relier un paiement à vous."
|
||||
payments: "Paiements"
|
||||
payments_blurb: "Chaque message nostr transportant un slatepack."
|
||||
usernames: "Noms d'utilisateur"
|
||||
usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st."
|
||||
price_avatars: "Prix"
|
||||
price_avatars_blurb: "Le taux en temps réel affiché à côté des montants."
|
||||
over_mixnet: "Via Tor"
|
||||
direct_connection: "Connexion directe"
|
||||
grin_node: "Nœud grin"
|
||||
grin_node_blurb: "Synchronisation des blocs et diffusion de votre transaction sur le réseau. Ce sont des données de chaîne publiques, identiques pour tous, et non liées à votre identité."
|
||||
pairing:
|
||||
title: "Appairage"
|
||||
intro: "Ce à quoi votre solde et vos montants sont comparés."
|
||||
pair_with: "Apparier avec"
|
||||
rates_note: "Les cours sont récupérés via Tor, uniquement tant qu'un appairage est actif — désactivé, aucune requête de cours ne quitte votre appareil."
|
||||
relays:
|
||||
title: "Relais"
|
||||
intro: "Les messages de paiement sont répliqués sur tous les relais ci-dessous ; un seul relais joignable suffit pour recevoir."
|
||||
your_relays: "Vos relais"
|
||||
add_relay: "Ajouter un relais"
|
||||
add_relay_btn: "Ajouter un relais"
|
||||
save_reconnect: "Enregistrer et reconnecter"
|
||||
none: "aucun"
|
||||
count: "%{n} relais"
|
||||
node:
|
||||
title: "Nœud"
|
||||
connection: "Connexion"
|
||||
integrated: "Nœud intégré"
|
||||
applies_after: "S'applique après le verrouillage puis déverrouillage du portefeuille."
|
||||
add_external: "Ajouter un nœud externe"
|
||||
api_secret_hint: "Secret API (facultatif)"
|
||||
add_node: "Ajouter le nœud"
|
||||
integrated_host: "nœud intégré"
|
||||
summary_syncing: "%{conn} · synchronisation"
|
||||
summary_block: "Bloc %{height} · %{conn}"
|
||||
nips:
|
||||
title: "nostr et NIP"
|
||||
intro1: "Goblin parle nostr — un protocole ouvert de messages signés transmis via de simples serveurs relais. Votre portefeuille porte sa propre identité nostr : une clé aléatoire autonome, gardée délibérément indépendante de vos fonds et de votre phrase de récupération. Chaque paiement voyage comme un message direct chiffré de bout en bout entre identités, le slatepack à l'intérieur."
|
||||
intro2: "goblin.st est le service de noms de Goblin : réserver un nom d'utilisateur y publie une correspondance nom → clé (NIP-05), pour qu'on puisse payer you au lieu d'un long npub. Le nom d'utilisateur est public ; le contenu des paiements ne l'est jamais. Les NIP sont les briques du protocole — touchez-en un pour lire la spécification."
|
||||
n05_title: "Noms"
|
||||
n05_blurb: "Associe username@goblin.st à votre clé, pour que les identifiants fonctionnent comme des adresses."
|
||||
n17_title: "Messages privés"
|
||||
n17_blurb: "L'enveloppe de DM chiffré dans laquelle voyage chaque paiement."
|
||||
n44_title: "Chiffrement"
|
||||
n44_blurb: "Le chiffrement authentifié utilisé à l'intérieur de ces messages."
|
||||
n49_title: "Chiffrement de clé"
|
||||
n49_blurb: "Comment la clé secrète est stockée au repos, verrouillée par votre mot de passe."
|
||||
n59_title: "Emballage cadeau"
|
||||
n59_blurb: "Enveloppe les messages pour que les relais ne voient pas qui parle à qui."
|
||||
n98_title: "Auth HTTP"
|
||||
n98_blurb: "Signe la demande d'enregistrement du nom d'utilisateur auprès de goblin.st."
|
||||
onboarding:
|
||||
intro:
|
||||
private_money_head: "Argent privé"
|
||||
private_money_body: "Goblin est un portefeuille pour grin — de l'argent numérique sans montants ni adresses sur sa chaîne."
|
||||
send_like_message_head: "Envoyer comme un message"
|
||||
send_like_message_body: "Payez un username ou un npub et cela arrive comme un message chiffré de bout en bout via nostr et Tor — personne entre les deux ne voit le montant ni les personnes impliquées."
|
||||
yours_alone_head: "À vous seul"
|
||||
yours_alone_body: "Clés, noms et historique vivent sur cet appareil. Construit sur le portefeuille GRIM."
|
||||
get_started: "Commencer"
|
||||
footnote: "Environ une minute. Vous pourrez tout changer plus tard."
|
||||
node:
|
||||
kicker: "ÉTAPE 1 SUR 3 · RÉSEAU"
|
||||
title: "Comment Goblin doit-il\nsurveiller la chaîne ?"
|
||||
own_title: "Lancer mon propre nœud"
|
||||
own_badge: "Privé"
|
||||
own_body: "Ne fait confiance à personne — votre portefeuille vérifie la chaîne lui-même. Se synchronise en arrière-plan pendant que vous terminez la configuration."
|
||||
connect_title: "Se connecter à un nœud"
|
||||
connect_badge: "Instantané"
|
||||
connect_body: "Aucune attente de synchronisation. Le nœud que vous choisissez peut voir les requêtes de votre portefeuille."
|
||||
changeable: "Modifiable à tout moment dans Réglages → Nœud."
|
||||
continue: "Continuer"
|
||||
url_invalid: "L'URL du nœud doit commencer par http:// ou https://"
|
||||
wallet:
|
||||
kicker: "ÉTAPE 2 SUR 3 · PORTEFEUILLE"
|
||||
title: "Configurez votre portefeuille"
|
||||
create_new: "Créer un nouveau"
|
||||
restore_from_seed: "Restaurer depuis la phrase"
|
||||
name_hint: "Nom du portefeuille"
|
||||
password_hint: "Mot de passe"
|
||||
repeat_password_hint: "Répéter le mot de passe"
|
||||
restore_hint: "Préparez vos mots de récupération — vous les saisirez ensuite."
|
||||
create_hint: "Vous obtiendrez ensuite 24 mots de récupération à noter. Ce sont l'argent — quiconque les détient détient vos fonds."
|
||||
continue: "Continuer"
|
||||
passwords_no_match: "Les mots de passe ne correspondent pas"
|
||||
words:
|
||||
kicker: "ÉTAPE 2 SUR 3 · PORTEFEUILLE"
|
||||
title_restore: "Saisissez vos mots de récupération"
|
||||
title_create: "Notez ces mots"
|
||||
write_down_hint: "Sur papier, dans l'ordre. Quiconque a ces mots peut prendre vos fonds ; sans eux, un appareil perdu signifie des fonds perdus."
|
||||
paste: "Coller"
|
||||
scan_qr: "Scanner le QR"
|
||||
copy_clipboard: "Copier dans le presse-papiers (à éviter)"
|
||||
restore_wallet: "Restaurer le portefeuille"
|
||||
wrote_them_down: "Je les ai notés"
|
||||
fill_every_word: "Remplissez chaque mot — touchez un mot pour le modifier, ou collez la phrase."
|
||||
confirm:
|
||||
kicker: "ÉTAPE 2 SUR 3 · PORTEFEUILLE"
|
||||
title: "Maintenant prouvez-le"
|
||||
enter_hint: "Saisissez les mots que vous venez de noter. Touchez un mot pour le taper."
|
||||
paste: "Coller"
|
||||
create_wallet: "Créer le portefeuille"
|
||||
keep_going: "Continuez — chaque mot, dans l'ordre."
|
||||
identity:
|
||||
kicker: "ÉTAPE 3 SUR 3 · IDENTITÉ"
|
||||
title: "Votre identité de paiement"
|
||||
key_being_made: "clé en cours de création…"
|
||||
connected_nym: "connecté via Tor"
|
||||
connecting_nym: "connexion via Tor…"
|
||||
fresh_key_blurb: "Une clé de paiement qui ne fait pas partie de votre seed — renouvelable à tout moment, sans toucher à vos fonds."
|
||||
clean_slate_blurb: "Envie de repartir à zéro ? Remplacez par une toute nouvelle clé à tout moment — le nouveau vous n'est pas lié à l'ancien. Même portefeuille, nouveau visage."
|
||||
pick_username: "Choisir un nom d'utilisateur — facultatif"
|
||||
username_blurb: "Vos amis paient votre nom au lieu d'une longue clé. Facultatif — réclamez-en un à tout moment."
|
||||
username_field_hint: "votrenom"
|
||||
working: "En cours…"
|
||||
claim_username: "Réserver le nom d'utilisateur"
|
||||
available_when_connected: "Disponible une fois Tor connecté — ou passez et réservez plus tard."
|
||||
youre: "Vous êtes %{name}"
|
||||
claimed_title: "%{name} est à vous"
|
||||
claimed_blurb: "Vos amis peuvent désormais vous payer par votre nom. Tout est prêt — ouvrez votre portefeuille."
|
||||
open_wallet: "Ouvrir mon portefeuille"
|
||||
skip_for_now: "Passer pour l'instant"
|
||||
import_existing: "Vous avez déjà une identité Goblin ? Importez-la"
|
||||
import_title: "Importer votre identité"
|
||||
import_blurb: "Collez votre nsec ou choisissez un fichier .backup pour conserver votre clé et votre nom existants au lieu de ce nouveau."
|
||||
errors:
|
||||
cant_open: "Impossible d'ouvrir le portefeuille : %{err}"
|
||||
cant_create: "Impossible de créer le portefeuille : %{err}"
|
||||
send:
|
||||
scan_to_request: "Scanner pour demander"
|
||||
scan_to_pay: "Scanner pour payer"
|
||||
tab_scan: "Scanner"
|
||||
tab_my_code: "Mon code"
|
||||
request_from: "Demander à"
|
||||
send_to: "Envoyer à"
|
||||
search_hint: "handle, npub ou nom"
|
||||
suggested: "%{icon} Suggéré"
|
||||
no_contacts: "Aucun contact pour l'instant. Trouvez quelqu'un par son handle."
|
||||
no_profile: "pas de profil"
|
||||
tag_contact: "contact"
|
||||
tag_on_nostr: "sur nostr"
|
||||
searching_nostr: "Recherche sur nostr…"
|
||||
unverified_title: "Payer une clé non vérifiée ?"
|
||||
unverified_body: "Aucun profil nostr n'est publié pour cette clé — elle peut être toute neuve, anonyme ou mal saisie. Vérifiez bien qu'il s'agit de la bonne avant d'envoyer."
|
||||
keep_looking: "Continuer à chercher"
|
||||
pay_anyway: "Payer quand même"
|
||||
scan_not_recipient: "Ce QR n'est pas un destinataire goblin — un npub ou handle est attendu"
|
||||
scan_prompt: "Placez un code goblin dans le champ pour activer"
|
||||
scan_to_pay_me: "Scannez pour me payer"
|
||||
share_btn: "%{icon} Partager"
|
||||
share_message: "Payez-moi sur Goblin — %{handle}\n%{link}\nnpub : %{npub}"
|
||||
none_found: "Personne trouvé pour %{label}"
|
||||
enter_recipient: "Saisissez un handle, un npub ou un nom"
|
||||
amount_title: "Montant"
|
||||
to_name: "À %{name}"
|
||||
not_enough: "Vous n'avez pas assez de grin"
|
||||
max: "Max"
|
||||
note_label: "Note"
|
||||
note_hint: "Ajouter une note…"
|
||||
add_note: "Ajouter une note"
|
||||
edit_note: "Modifier la note"
|
||||
note_cancel: "Annuler"
|
||||
note_save: "Enregistrer"
|
||||
review_btn: "Vérifier"
|
||||
confirm_request: "Confirmer la demande"
|
||||
review_title: "Vérification"
|
||||
requesting_from: "Demande à %{name}"
|
||||
youre_sending: "Vous envoyez %{name}"
|
||||
row_from: "De"
|
||||
row_to: "À"
|
||||
row_note: "Note"
|
||||
row_proof: "Preuve de paiement"
|
||||
row_proof_val: "Incluse"
|
||||
row_proof_shared: "Preuve partagée avec"
|
||||
row_they_pay: "Ils paient"
|
||||
row_they_pay_val: "Seulement s'ils approuvent"
|
||||
row_delivery: "Livraison"
|
||||
row_delivery_val: "Chiffré NIP-44, via Tor"
|
||||
row_network_fee: "Frais de réseau"
|
||||
row_network_fee_val: "Déduit de votre solde"
|
||||
row_privacy: "Confidentialité"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "Envoyer la demande"
|
||||
request_approve_hint: "Ils recevront une demande à approuver"
|
||||
hold_to_send: "Maintenir pour envoyer"
|
||||
lower_amount: "Revenez et baissez le montant"
|
||||
hold_confirm_hint: "Appuyez et maintenez pour confirmer"
|
||||
requesting: "Demande en cours…"
|
||||
sending: "Envoi…"
|
||||
they: "Ils"
|
||||
request_blocked: "%{who} n'accepte pas les demandes. Demandez-lui de vous envoyer des grin à la place."
|
||||
failed_request_title: "Échec de la demande"
|
||||
failed_send_title: "Échec de l'envoi"
|
||||
failed_request_body: "Impossible de livrer la demande. Demandez-lui de vous envoyer des grin à la place."
|
||||
failed_send_body: "Le paiement n'a pas été livré. Vos grin sont en sécurité — réessayez."
|
||||
try_again_btn: "Réessayer"
|
||||
close_btn: "Fermer"
|
||||
success:
|
||||
requested: "Demandé"
|
||||
sent: "Envoyé"
|
||||
from: "de"
|
||||
to: "à"
|
||||
subtitle: "%{dir} %{who} · à l'instant"
|
||||
done_btn: "Terminé"
|
||||
receipt_btn: "Reçu"
|
||||
@@ -44,7 +44,7 @@ wallets:
|
||||
await_fin_amount: Ожидает завершения
|
||||
locked_amount: Заблокировано
|
||||
txs_empty: 'Для получения средств вручную или через транспорт используйте кнопки %{message} или %{transport} внизу экрана, для изменения настроек кошелька нажмите кнопку %{settings}.'
|
||||
title: Кошельки
|
||||
title: Goblin
|
||||
create_desc: Создайте или импортируйте существующий кошелёк из сохранённой фразы восстановления.
|
||||
add: Добавить кошелёк
|
||||
name: 'Название:'
|
||||
@@ -355,4 +355,478 @@ keyboard:
|
||||
m: ь
|
||||
m1: б
|
||||
m2: ю
|
||||
m3: ё
|
||||
m3: ё
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Аноним"
|
||||
connected_nym: "Подключено через Tor"
|
||||
nym_ready: "Tor готов · реле…"
|
||||
connecting_nym: "Подключение к Tor…"
|
||||
cant_reach_node: "Нет связи с узлом"
|
||||
node_synced: "Узел синхронизирован"
|
||||
syncing: "Синхронизация…"
|
||||
balance_updating: "Баланс обновляется…"
|
||||
balance_stale: "Узел недоступен · последний известный баланс"
|
||||
fiat_unavailable: "Курс недоступен"
|
||||
listening: "Ожидание платежей"
|
||||
block: "Блок %{height}"
|
||||
waiting_for_chain: "Ожидание цепочки…"
|
||||
nav_wallet: "Кошелёк"
|
||||
nav_pay: "Оплатить"
|
||||
nav_activity: "Действия"
|
||||
nav_receive: "Получить"
|
||||
nav_settings: "Настройки"
|
||||
activity: "Действия"
|
||||
news: "Новости"
|
||||
empty_title: "Пока нет действий"
|
||||
empty_sub: "Отправьте или получите grin, чтобы начать."
|
||||
recent: "Недавние"
|
||||
scan_to_pay: "Сканируйте для оплаты"
|
||||
type_amount: "Введите сумму"
|
||||
request: "Запросить"
|
||||
pay: "Оплатить"
|
||||
enter_amount: "Введите сумму для оплаты или запроса"
|
||||
activity:
|
||||
canceled: "отменено"
|
||||
pending: "в ожидании"
|
||||
earlier: "Ранее"
|
||||
today: "Сегодня"
|
||||
yesterday: "Вчера"
|
||||
title: "Действия"
|
||||
requests: "Запросы"
|
||||
empty_title: "Пока нет действий"
|
||||
empty_sub: "Здесь появятся ваши платежи."
|
||||
pending_header: "В ожидании"
|
||||
receipt:
|
||||
title: "Квитанция"
|
||||
not_found: "Транзакция не найдена"
|
||||
for_note: "За %{note}"
|
||||
details: "Детали транзакции"
|
||||
canceled: "Отменено"
|
||||
expired: "Истекло"
|
||||
funds_returned: "Средства возвращены"
|
||||
complete: "Завершено"
|
||||
payment_received: "Платёж получен"
|
||||
payment_sent: "Платёж успешно отправлен"
|
||||
pending: "В ожидании"
|
||||
confs: "%{c}/%{r} подтверждений"
|
||||
waiting_to_confirm: "Ожидание подтверждения"
|
||||
paying: "Оплата…"
|
||||
you: "Вы"
|
||||
to: "Кому"
|
||||
from: "От"
|
||||
nostr: "nostr"
|
||||
fee_none: "Нет"
|
||||
network_fee: "Сетевая комиссия"
|
||||
privacy: "Приватность"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Транзакция"
|
||||
cancel_request: "Отменить запрос"
|
||||
cancel_send: "Отменить платёж"
|
||||
cancel_send_confirm: "Нажмите ещё раз для отмены — он ещё может его получить"
|
||||
cancel_send_done: "Платёж отменён — ваши средства снова доступны"
|
||||
cancel_send_too_late: "Этот платёж уже прошёл и не может быть отменён"
|
||||
waiting_to_receive: "Ожидание, пока %{name} получит…"
|
||||
request:
|
||||
title: "%{name} запрашивает"
|
||||
approve: "Принять"
|
||||
decline: "Отклонить"
|
||||
review_title: "Проверить запрос"
|
||||
hold_to_accept: "Удерживайте, чтобы принять"
|
||||
hold_accept_hint: "Нажмите и удерживайте, чтобы оплатить запрос"
|
||||
receive:
|
||||
title: "Получить"
|
||||
requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату"
|
||||
clear_request: "Очистить запрос"
|
||||
share_handle: "Поделитесь именем, чтобы получить оплату"
|
||||
share_npub: "Поделитесь своим npub, чтобы получить оплату"
|
||||
copied: "Скопировано"
|
||||
copy_nostr_id: "Копировать nostr ID"
|
||||
copy_address: "Копировать адрес"
|
||||
copy_npub: "Копировать npub"
|
||||
share_message: "Заплатите мне в Goblin (goblin.st) — %{npub}"
|
||||
privacy_note: "Ваше имя публично. Содержимое платежей остаётся зашифрованным в сети."
|
||||
privacy_note_npub: "Ваш npub публичен. Содержимое платежей остаётся зашифрованным в сети."
|
||||
profile:
|
||||
title: "Профиль"
|
||||
activity: "Действия"
|
||||
no_activity: "Пока нет действий с ними."
|
||||
unblock: "Разблокировать"
|
||||
block: "Заблокировать"
|
||||
blocked_blurb: "Заблокирован — их платежи и запросы отклоняются."
|
||||
block_blurb: "Блокировка отклоняет их входящие платежи и запросы."
|
||||
settings:
|
||||
title: "Настройки"
|
||||
connected_nostr: "Подключено к nostr"
|
||||
connecting_relays: "Подключение к реле…"
|
||||
identity: "Личность"
|
||||
copy_npub: "Копировать npub (публичный)"
|
||||
rotate_key: "Сменить ключ nostr"
|
||||
import_identity: "Импорт личности (.backup / nsec)"
|
||||
backup_note: "Меняете устройство? Сохраните ОБА: seed-фразу (средства) и файл .backup личности (имя + ключ)."
|
||||
wallet: "Кошелёк"
|
||||
display_unit: "Единица отображения"
|
||||
relays: "Реле"
|
||||
nostr_relays: "Реле Nostr"
|
||||
node: "Узел"
|
||||
integrated_node: "Настройки встроенного узла"
|
||||
node_advanced: "Дополнительно"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Ручная транзакция"
|
||||
lock_wallet: "Заблокировать кошелёк"
|
||||
switch_wallet: "Сменить кошелёк"
|
||||
advanced: "Дополнительно"
|
||||
privacy: "Приватность"
|
||||
mixnet_routing: "Маршрутизация через Tor"
|
||||
messages_lookups: "Сообщения и поиск"
|
||||
auto_accept: "Автоприём"
|
||||
pairing: "Валюта цены"
|
||||
accept_anyone: "Любой"
|
||||
accept_contacts: "Только контакты"
|
||||
accept_ask: "Всегда спрашивать"
|
||||
requests: "Запросы"
|
||||
incoming_requests: "Входящие запросы"
|
||||
incoming_requests_sub: "Разрешить другим запрашивать у вас деньги"
|
||||
hide_amounts: "Скрыть суммы"
|
||||
hide_amounts_sub: "Скрывать полученные суммы в уведомлениях"
|
||||
language: "Язык"
|
||||
update_available: "Доступно обновление"
|
||||
appearance: "Внешний вид"
|
||||
theme: "Тема"
|
||||
theme_light: "Светлая"
|
||||
theme_dark: "Тёмная"
|
||||
theme_yellow: "Жёлтая"
|
||||
archive: "Архив"
|
||||
export_archive: "Экспорт архива"
|
||||
wipe_history: "Стереть историю платежей"
|
||||
wipe_history_confirm: "Нажмите ещё раз, чтобы стереть — это нельзя отменить"
|
||||
about: "О приложении"
|
||||
goblin: "Goblin"
|
||||
build: "Сборка %{build}"
|
||||
network: "Сеть"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Сторонние"
|
||||
grim: "GRIM (исходный кошелёк)"
|
||||
grin_node: "Узел Grin"
|
||||
sp_intro: "Для опытных — обмен сырыми slatepacks вручную, как в GRIM. Используйте только если не можете платить или получать через username."
|
||||
sp_receive_group: "Получить или завершить"
|
||||
sp_receive_blurb: "Вставьте slatepack, который вам дали. Goblin получит платёж, оплатит счёт или завершит и опубликует его."
|
||||
sp_process: "Обработать slatepack"
|
||||
sp_paste_first: "Сначала вставьте slatepack."
|
||||
sp_reply_ready: "Ответ готов — отправьте его обратно отправителю."
|
||||
sp_finalizing: "Завершение и публикация в цепочку…"
|
||||
sp_create_group: "Создать платёж"
|
||||
sp_create_blurb: "Создайте slatepack для передачи кому-то. Они получат его, отправят ответ, а вы завершите его выше."
|
||||
sp_amount_hint: "Сумма в grin"
|
||||
sp_addr_hint: "Адрес получателя (необязательно)"
|
||||
sp_create: "Создать slatepack"
|
||||
sp_ready: "Slatepack готов — передайте его получателю."
|
||||
sp_amount_gt_zero: "Введите сумму больше нуля."
|
||||
sp_to_send: "Slatepack для отправки"
|
||||
sp_copy: "Копировать slatepack"
|
||||
rotate_line1: "• Вы получите совершенно новый СЛУЧАЙНЫЙ ключ; старый npub перестанет принимать. Между ними нет цепочки вывода."
|
||||
rotate_line2: "• Новый ключ НЕЛЬЗЯ восстановить из seed — сохраните новый nsec сразу после смены."
|
||||
rotate_line3: "• Ваше имя пользователя ОСВОБОЖДАЕТСЯ — сразу после заявите то же или новое имя (как только свободно, его может занять кто угодно)."
|
||||
rotate_line4: "• Платежи, всё ещё идущие к старому ключу, БУДУТ нарушены — сначала дождитесь завершения ожидающих платежей."
|
||||
rotate_line5: "• Контакты, сохранившие ваш npub напрямую, должны найти вас заново — поделитесь новым npub или заново занятым username."
|
||||
cancel: "Отмена"
|
||||
continue: "Продолжить"
|
||||
final_confirmation: "Финальное подтверждение"
|
||||
rotate_confirm_blurb: "Это нельзя отменить из приложения. Введите RESET и пароль кошелька, чтобы сменить."
|
||||
type_reset: "Введите RESET"
|
||||
wallet_password: "Пароль кошелька"
|
||||
rotate_key_btn: "Сменить ключ"
|
||||
rotating_key: "Смена ключа…"
|
||||
key_rotated: "Ключ сменён"
|
||||
new_npub: "Новый npub: %{npub}"
|
||||
backup_new_key: "Сохраните НОВЫЙ секретный ключ сейчас — seed не сможет его восстановить."
|
||||
copy_new_nsec: "Копировать резерв нового nsec"
|
||||
done: "Готово"
|
||||
rotation_failed: "Смена не удалась"
|
||||
close: "Закрыть"
|
||||
import_identity_title: "Импорт личности"
|
||||
import_blurb: "Заменяет nostr-личность этого кошелька — выберите файл GOBLIN .backup или вставьте nsec. Резервная копия также восстанавливает имя и историю. Сначала сохраните текущий ключ, если он ещё нужен."
|
||||
import_nsec_hint: "nsec1… или вставленная копия"
|
||||
backup_password_hint: "Пароль резерва (только если экспортирован в другом месте)"
|
||||
import_btn: "Импорт"
|
||||
importing: "Импорт…"
|
||||
identity_replaced: "Личность заменена"
|
||||
now_using: "Сейчас используется: %{npub}"
|
||||
import_failed: "Импорт не удался"
|
||||
name_authority: "Сервер имён"
|
||||
name_authority_title: "Сменить сервер имён"
|
||||
name_authority_blurb: "Сервер, который регистрирует и проверяет имена. Укажите другой инстанс, чтобы использовать и оплачивать имена оттуда."
|
||||
name_authority_invalid: "Введите полный URL (https://…)."
|
||||
reset: "Сброс"
|
||||
save: "Сохранить"
|
||||
backup_file: "Сохранить в файл"
|
||||
choose_backup_file: "Выбрать файл .backup"
|
||||
backup_read_failed: "Не удалось прочитать файл."
|
||||
backup_saved: "Резервная копия сохранена"
|
||||
backup_saved_sub: "Храните файл .backup в безопасности — любой, у кого есть он И ваш пароль, может восстановить вашу личность."
|
||||
backup_file_title: "Резервная копия личности"
|
||||
backup_file_blurb: "Создаёт один зашифрованный файл .backup с именем и ключом. Введите пароль кошелька, чтобы запечатать его."
|
||||
backup_write_failed: "Не удалось сохранить файл."
|
||||
create_backup: "Создать копию"
|
||||
registered: "Зарегистрировано %{name}"
|
||||
released_msg: "Освобождено — имя свободно для занятия"
|
||||
release_confirm: "Освободить %{name}?"
|
||||
release_blurb: "Как только оно свободно, его можно занять — кто угодно, включая ваш следующий ключ. Вы не сможете зарегистрировать другое имя в течение 10 минут."
|
||||
releasing: "Освобождение…"
|
||||
keep_it: "Оставить"
|
||||
release_it: "Освободить"
|
||||
username: "Имя пользователя"
|
||||
username_note: "Отображается как ваше имя. Публично на goblin.st. Платежи остаются зашифрованными."
|
||||
release_username: "Освободить имя"
|
||||
pick_username: "Выберите имя — необязательно"
|
||||
working: "Обработка…"
|
||||
claim: "Занять"
|
||||
err_just_taken: "Это имя только что заняли"
|
||||
err_cooldown: "Вы недавно освободили имя — можно зарегистрировать новое в течение 10 минут."
|
||||
err_unreachable: "Не удалось связаться с goblin.st — сбой соединения. Попробуйте снова."
|
||||
err_release: "Не удалось освободить: %{err}"
|
||||
avail_available: "Доступно!"
|
||||
avail_taken: "Занято"
|
||||
avail_reserved: "Зарезервировано"
|
||||
avail_invalid: "Имена 3–20 символов: a–z, 0–9, _ или -"
|
||||
avail_quarantined: "Недоступно"
|
||||
avail_unknown: "Не удалось проверить — сбой соединения. Попробуйте снова."
|
||||
advanced:
|
||||
title: "Дополнительно"
|
||||
intro: "Низкоуровневые инструменты кошелька из GRIM. Обычно они вам не нужны."
|
||||
own_node_desc: "Синхронизируйте полный узел Grin на этом устройстве вместо доверия публичному."
|
||||
own_node_active: "Ваш узел запущен"
|
||||
repair: "Починить кошелёк"
|
||||
repair_desc: "Повторно просканировать цепочку и восстановить недостающие выходы. Это может занять время."
|
||||
repair_unavailable: "Сначала нужно синхронизированное подключение к узлу."
|
||||
repairing: "Починка… %{pct}%"
|
||||
restore: "Восстановить кошелёк"
|
||||
restore_desc: "Удалить локальные данные и пересоздать из seed-фразы. Используйте, если починка не помогла — после этого откройте кошелёк заново."
|
||||
restore_confirm: "Нажмите ещё раз для восстановления"
|
||||
show_phrase: "Фраза восстановления"
|
||||
phrase_desc: "Ваши 24 seed-слова grin — единственный способ восстановить средства. Храните их офлайн и в тайне."
|
||||
reveal: "Показать фразу"
|
||||
hide: "Скрыть"
|
||||
password: "Пароль кошелька"
|
||||
wrong_password: "Неверный пароль."
|
||||
delete: "Удалить кошелёк"
|
||||
delete_desc: "Безвозвратно удалить этот кошелёк с этого устройства. Без seed-фразы средства не восстановить."
|
||||
delete_confirm: "Нажмите ещё раз для удаления"
|
||||
manage_node: "Управление подключением к узлу"
|
||||
repair_confirm: "Да, восстановить сейчас"
|
||||
repair_confirm_note: "Восстановление повторно сканирует цепочку и может занять несколько минут."
|
||||
restore_confirm_note: "Это стирает локальные данные и восстанавливает их из seed-фразы — может занять несколько минут."
|
||||
nostr_key: "Ключ Nostr"
|
||||
nostr_key_desc: "Ваш nsec, секретный ключ вашей личности Nostr. Скопируйте его или покажите QR-код, чтобы войти в приложения Nostr, такие как magick.market. Любой, у кого он есть, управляет вашей личностью, держите его в секрете."
|
||||
reveal_nsec: "Показать ключ"
|
||||
copy_nsec: "Копировать nsec"
|
||||
show_qr: "Показать QR"
|
||||
hide_qr: "Скрыть QR"
|
||||
privacy:
|
||||
title: "Сетевая приватность"
|
||||
intro: "Goblin отправляет приватный трафик через Tor, который скрывает ваш IP от реле — шифрование скрывает остальное, чтобы реле не могло связать платёж с вами."
|
||||
payments: "Платежи"
|
||||
payments_blurb: "Каждое nostr-сообщение, несущее slatepack."
|
||||
usernames: "Имена пользователей"
|
||||
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
|
||||
price_avatars: "Цена"
|
||||
price_avatars_blurb: "Текущий курс рядом с суммами."
|
||||
over_mixnet: "Через Tor"
|
||||
direct_connection: "Прямое соединение"
|
||||
grin_node: "Узел Grin"
|
||||
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
|
||||
pairing:
|
||||
title: "Привязка"
|
||||
intro: "К чему привязаны отображаемые баланс и суммы."
|
||||
pair_with: "Привязать к"
|
||||
rates_note: "Курсы загружаются через Tor только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
||||
relays:
|
||||
title: "Реле"
|
||||
intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле."
|
||||
your_relays: "Ваши реле"
|
||||
add_relay: "Добавить реле"
|
||||
add_relay_btn: "Добавить реле"
|
||||
save_reconnect: "Сохранить и переподключить"
|
||||
none: "нет"
|
||||
count: "%{n} реле"
|
||||
node:
|
||||
title: "Узел"
|
||||
connection: "Соединение"
|
||||
integrated: "Встроенный узел"
|
||||
applies_after: "Применяется после блокировки и повторной разблокировки кошелька."
|
||||
add_external: "Добавить внешний узел"
|
||||
api_secret_hint: "API-секрет (необязательно)"
|
||||
add_node: "Добавить узел"
|
||||
integrated_host: "встроенный узел"
|
||||
summary_syncing: "%{conn} · синхронизация"
|
||||
summary_block: "Блок %{height} · %{conn}"
|
||||
nips:
|
||||
title: "nostr и NIPs"
|
||||
intro1: "Goblin говорит на nostr — открытом протоколе подписанных сообщений, передаваемых через простые реле-серверы. Ваш кошелёк несёт собственную nostr-личность: отдельный случайный ключ, намеренно независимый от ваших средств и seed. Каждый платёж идёт как сквозно зашифрованное личное сообщение между личностями, со slatepack внутри."
|
||||
intro2: "goblin.st — это служба имён Goblin: занятие имени публикует там сопоставление имя → ключ (NIP-05), чтобы вам платили на you вместо длинного npub. Имя публично; содержимое платежей — никогда. NIPs — это строительные блоки протокола; коснитесь одного, чтобы прочитать спецификацию."
|
||||
n05_title: "Имена"
|
||||
n05_blurb: "Сопоставляет username@goblin.st с вашим ключом, чтобы имена работали как адреса."
|
||||
n17_title: "Личные сообщения"
|
||||
n17_blurb: "Зашифрованный конверт DM, в котором идёт каждый платёж."
|
||||
n44_title: "Шифрование"
|
||||
n44_blurb: "Аутентифицированный шифр, используемый внутри этих сообщений."
|
||||
n49_title: "Шифрование ключа"
|
||||
n49_blurb: "Как секретный ключ хранится в покое, защищённый вашим паролем."
|
||||
n59_title: "Подарочная обёртка"
|
||||
n59_blurb: "Оборачивает сообщения, чтобы реле не видели, кто с кем общается."
|
||||
n98_title: "HTTP-авторизация"
|
||||
n98_blurb: "Подписывает запрос регистрации имени на goblin.st."
|
||||
onboarding:
|
||||
intro:
|
||||
private_money_head: "Приватные деньги"
|
||||
private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке."
|
||||
send_like_message_head: "Отправляйте как сообщение"
|
||||
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и Tor — никто посередине не увидит сумму или участников."
|
||||
yours_alone_head: "Только ваше"
|
||||
yours_alone_body: "Ключи, имена и история живут на этом устройстве. На базе кошелька GRIM."
|
||||
get_started: "Начать"
|
||||
footnote: "Займёт около минуты. Всё можно изменить позже."
|
||||
node:
|
||||
kicker: "ШАГ 1 ИЗ 3 · СЕТЬ"
|
||||
title: "Как Goblin должен\nследить за цепочкой?"
|
||||
own_title: "Запустить свой узел"
|
||||
own_badge: "Приватно"
|
||||
own_body: "Никому не доверяет — ваш кошелёк проверяет цепочку сам. Синхронизируется в фоне, пока вы завершаете настройку."
|
||||
connect_title: "Подключиться к узлу"
|
||||
connect_badge: "Мгновенно"
|
||||
connect_body: "Без ожидания синхронизации. Выбранный узел может видеть запросы вашего кошелька."
|
||||
changeable: "Меняется в любой момент в Настройки → Узел."
|
||||
continue: "Продолжить"
|
||||
url_invalid: "URL узла должен начинаться с http:// или https://"
|
||||
wallet:
|
||||
kicker: "ШАГ 2 ИЗ 3 · КОШЕЛЁК"
|
||||
title: "Настройте кошелёк"
|
||||
create_new: "Создать новый"
|
||||
restore_from_seed: "Восстановить из seed"
|
||||
name_hint: "Имя кошелька"
|
||||
password_hint: "Пароль"
|
||||
repeat_password_hint: "Повторите пароль"
|
||||
restore_hint: "Подготовьте seed-слова — вы введёте их далее."
|
||||
create_hint: "Далее вы получите 24 seed-слова для записи. Они — это деньги: кто владеет ими, владеет вашими средствами."
|
||||
continue: "Продолжить"
|
||||
passwords_no_match: "Пароли не совпадают"
|
||||
words:
|
||||
kicker: "ШАГ 2 ИЗ 3 · КОШЕЛЁК"
|
||||
title_restore: "Введите seed-слова"
|
||||
title_create: "Запишите эти слова"
|
||||
write_down_hint: "На бумаге, по порядку. Любой с этими словами может забрать ваши средства; без них потеря устройства означает потерю средств."
|
||||
paste: "Вставить"
|
||||
scan_qr: "Сканировать QR"
|
||||
copy_clipboard: "Копировать в буфер (избегайте этого)"
|
||||
restore_wallet: "Восстановить кошелёк"
|
||||
wrote_them_down: "Я записал их"
|
||||
fill_every_word: "Заполните каждое слово — коснитесь слова для редактирования или вставьте фразу."
|
||||
confirm:
|
||||
kicker: "ШАГ 2 ИЗ 3 · КОШЕЛЁК"
|
||||
title: "Теперь подтвердите"
|
||||
enter_hint: "Введите слова, которые только что записали. Коснитесь слова, чтобы ввести его."
|
||||
paste: "Вставить"
|
||||
create_wallet: "Создать кошелёк"
|
||||
keep_going: "Продолжайте — каждое слово, по порядку."
|
||||
identity:
|
||||
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
|
||||
title: "Ваша платёжная личность"
|
||||
key_being_made: "ключ создаётся…"
|
||||
connected_nym: "подключено через Tor"
|
||||
connecting_nym: "подключение через Tor…"
|
||||
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
|
||||
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
|
||||
pick_username: "Выберите имя — необязательно"
|
||||
username_blurb: "Друзья платят на ваше имя, а не на длинный ключ. Необязательно — можно занять в любой момент."
|
||||
username_field_hint: "вашеимя"
|
||||
working: "Обработка…"
|
||||
claim_username: "Занять имя"
|
||||
available_when_connected: "Доступно после подключения Tor — или пропустите и займите позже."
|
||||
youre: "Вы %{name}"
|
||||
claimed_title: "%{name} теперь ваше"
|
||||
claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк."
|
||||
open_wallet: "Открыть кошелёк"
|
||||
skip_for_now: "Пропустить пока"
|
||||
import_existing: "Уже есть личность Goblin? Импортируйте её"
|
||||
import_title: "Импорт личности"
|
||||
import_blurb: "Вставьте свой nsec или выберите файл .backup, чтобы сохранить существующий ключ и имя вместо нового."
|
||||
errors:
|
||||
cant_open: "Не удалось открыть кошелёк: %{err}"
|
||||
cant_create: "Не удалось создать кошелёк: %{err}"
|
||||
send:
|
||||
scan_to_request: "Сканируйте для запроса"
|
||||
scan_to_pay: "Сканируйте для оплаты"
|
||||
tab_scan: "Сканировать"
|
||||
tab_my_code: "Мой код"
|
||||
request_from: "Запросить у"
|
||||
send_to: "Отправить"
|
||||
search_hint: "handle, npub или имя"
|
||||
suggested: "%{icon} Рекомендуемые"
|
||||
no_contacts: "Пока нет контактов. Найдите кого-то по их handle."
|
||||
no_profile: "нет профиля"
|
||||
tag_contact: "контакт"
|
||||
tag_on_nostr: "в nostr"
|
||||
searching_nostr: "Поиск в nostr…"
|
||||
unverified_title: "Заплатить непроверенному ключу?"
|
||||
unverified_body: "Для этого ключа не опубликован nostr-профиль — он может быть совсем новым, анонимным или с опечаткой. Дважды проверьте перед отправкой."
|
||||
keep_looking: "Продолжить поиск"
|
||||
pay_anyway: "Всё равно оплатить"
|
||||
scan_not_recipient: "Этот QR — не получатель goblin; ожидался npub или handle"
|
||||
scan_prompt: "Наведите на код goblin, чтобы активировать"
|
||||
scan_to_pay_me: "Сканируйте, чтобы заплатить мне"
|
||||
share_btn: "%{icon} Поделиться"
|
||||
share_message: "Заплатите мне в Goblin — %{handle}\n%{link}\nnpub: %{npub}"
|
||||
none_found: "Никого не найдено по %{label}"
|
||||
enter_recipient: "Введите handle, npub или имя"
|
||||
amount_title: "Сумма"
|
||||
to_name: "Кому %{name}"
|
||||
not_enough: "Недостаточно grin"
|
||||
max: "Макс"
|
||||
note_label: "Заметка"
|
||||
note_hint: "Добавить заметку…"
|
||||
add_note: "Добавить заметку"
|
||||
edit_note: "Изменить заметку"
|
||||
note_cancel: "Отмена"
|
||||
note_save: "Сохранить"
|
||||
review_btn: "Проверить"
|
||||
confirm_request: "Подтвердить запрос"
|
||||
review_title: "Проверка"
|
||||
requesting_from: "Запрос у %{name}"
|
||||
youre_sending: "Вы отправляете %{name}"
|
||||
row_from: "От"
|
||||
row_to: "Кому"
|
||||
row_note: "Заметка"
|
||||
row_proof: "Подтверждение платежа"
|
||||
row_proof_val: "Включено"
|
||||
row_proof_shared: "Подтверждение получит"
|
||||
row_they_pay: "Они платят"
|
||||
row_they_pay_val: "Только если они одобрят"
|
||||
row_delivery: "Доставка"
|
||||
row_delivery_val: "Зашифровано NIP-44, через Tor"
|
||||
row_network_fee: "Сетевая комиссия"
|
||||
row_network_fee_val: "Списывается с вашего баланса"
|
||||
row_privacy: "Приватность"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "Отправить запрос"
|
||||
request_approve_hint: "Они получат запрос на одобрение"
|
||||
hold_to_send: "Удерживайте для отправки"
|
||||
lower_amount: "Вернуться и уменьшить сумму"
|
||||
hold_confirm_hint: "Нажмите и удерживайте для подтверждения"
|
||||
requesting: "Запрос…"
|
||||
sending: "Отправка…"
|
||||
they: "Они"
|
||||
request_blocked: "%{who} не принимает запросы. Попросите их отправить вам grin вместо этого."
|
||||
failed_request_title: "Не удалось запросить"
|
||||
failed_send_title: "Не удалось отправить"
|
||||
failed_request_body: "Не удалось доставить запрос. Попросите их отправить вам grin вместо этого."
|
||||
failed_send_body: "Платёж не доставлен. Ваш grin в безопасности — попробуйте снова."
|
||||
try_again_btn: "Попробовать снова"
|
||||
close_btn: "Закрыть"
|
||||
success:
|
||||
requested: "Запрошено"
|
||||
sent: "Отправлено"
|
||||
from: "от"
|
||||
to: "кому"
|
||||
subtitle: "%{dir} %{who} · только что"
|
||||
done_btn: "Готово"
|
||||
receipt_btn: "Квитанция"
|
||||
@@ -44,7 +44,7 @@ wallets:
|
||||
await_fin_amount: Tamamlanma bekleniyor
|
||||
locked_amount: Kilitli
|
||||
txs_empty: 'Koinleri al/gonder icin ekranin altinda bulunan %{receive} / %{send} sekmeleri, cuzdan ayarlar icin %{settings} sekmesini kullanin.'
|
||||
title: Cuzdanlar
|
||||
title: Goblin
|
||||
create_desc: Yeni cuzdan olustur veya var olan bakiyeli cuzdani kurtarma kelimelerinizle canlandirin.
|
||||
add: Cuzdan ekle
|
||||
name: 'Ad:'
|
||||
@@ -355,4 +355,478 @@ keyboard:
|
||||
m: m
|
||||
m1: ','
|
||||
m2: .
|
||||
m3: /
|
||||
m3: /
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonim"
|
||||
connected_nym: "Tor üzerinden bağlı"
|
||||
nym_ready: "Tor hazır · relaylar…"
|
||||
connecting_nym: "Tor'a bağlanılıyor…"
|
||||
cant_reach_node: "Düğüme ulaşılamıyor"
|
||||
node_synced: "Düğüm eşitlendi"
|
||||
syncing: "Eşitleniyor…"
|
||||
balance_updating: "Bakiye güncelleniyor…"
|
||||
balance_stale: "Düğüme ulaşılamıyor · son bilinen bakiye"
|
||||
fiat_unavailable: "Kur mevcut değil"
|
||||
listening: "Ödemeler bekleniyor"
|
||||
block: "Blok %{height}"
|
||||
waiting_for_chain: "Zincir bekleniyor…"
|
||||
nav_wallet: "Cüzdan"
|
||||
nav_pay: "Öde"
|
||||
nav_activity: "Etkinlik"
|
||||
nav_receive: "Al"
|
||||
nav_settings: "Ayarlar"
|
||||
activity: "Etkinlik"
|
||||
news: "Haberler"
|
||||
empty_title: "Henüz etkinlik yok"
|
||||
empty_sub: "Başlamak için grin gönder ya da al."
|
||||
recent: "Son işlemler"
|
||||
scan_to_pay: "Ödemek için tara"
|
||||
type_amount: "Bir tutar gir"
|
||||
request: "İste"
|
||||
pay: "Öde"
|
||||
enter_amount: "Ödemek ya da istemek için bir tutar gir"
|
||||
activity:
|
||||
canceled: "iptal edildi"
|
||||
pending: "beklemede"
|
||||
earlier: "Daha önce"
|
||||
today: "Bugün"
|
||||
yesterday: "Dün"
|
||||
title: "Etkinlik"
|
||||
requests: "İstekler"
|
||||
empty_title: "Henüz etkinlik yok"
|
||||
empty_sub: "Ödemelerin burada görünecek."
|
||||
pending_header: "Beklemede"
|
||||
receipt:
|
||||
title: "Makbuz"
|
||||
not_found: "İşlem bulunamadı"
|
||||
for_note: "%{note} için"
|
||||
details: "İşlem ayrıntıları"
|
||||
canceled: "İptal edildi"
|
||||
expired: "Süresi doldu"
|
||||
funds_returned: "Para iade edildi"
|
||||
complete: "Tamamlandı"
|
||||
payment_received: "Ödeme alındı"
|
||||
payment_sent: "Ödeme başarıyla gönderildi"
|
||||
pending: "Beklemede"
|
||||
confs: "%{c}/%{r} onay"
|
||||
waiting_to_confirm: "Onay bekleniyor"
|
||||
paying: "Ödeniyor…"
|
||||
you: "Sen"
|
||||
to: "Alıcı"
|
||||
from: "Gönderen"
|
||||
nostr: "nostr"
|
||||
fee_none: "Yok"
|
||||
network_fee: "Ağ ücreti"
|
||||
privacy: "Gizlilik"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "İşlem"
|
||||
cancel_request: "İsteği iptal et"
|
||||
cancel_send: "Ödemeyi iptal et"
|
||||
cancel_send_confirm: "İptal için tekrar dokun — hâlâ alabilir"
|
||||
cancel_send_done: "Ödeme iptal edildi — paranız yeniden kullanılabilir"
|
||||
cancel_send_too_late: "Bu ödeme zaten geçti ve iptal edilemez"
|
||||
waiting_to_receive: "%{name} alana kadar bekleniyor…"
|
||||
request:
|
||||
title: "%{name} istiyor"
|
||||
approve: "Onayla"
|
||||
decline: "Reddet"
|
||||
review_title: "İsteği incele"
|
||||
hold_to_accept: "Kabul için basılı tut"
|
||||
hold_accept_hint: "Bu isteği ödemek için basılı tutun"
|
||||
receive:
|
||||
title: "Al"
|
||||
requesting: "%{amt}%{tsu} isteniyor — ödeme almak için paylaş"
|
||||
clear_request: "İsteği temizle"
|
||||
share_handle: "Ödeme almak için kullanıcı adını paylaş"
|
||||
share_npub: "Ödeme almak için npub'ını paylaş"
|
||||
copied: "Kopyalandı"
|
||||
copy_nostr_id: "nostr kimliğini kopyala"
|
||||
copy_address: "Adresi kopyala"
|
||||
copy_npub: "npub kopyala"
|
||||
share_message: "Goblin'de bana öde (goblin.st) — %{npub}"
|
||||
privacy_note: "Kullanıcı adın herkese açıktır. Ödeme içeriği ağ üzerinde şifreli kalır."
|
||||
privacy_note_npub: "npub'ın herkese açıktır. Ödeme içeriği ağ üzerinde şifreli kalır."
|
||||
profile:
|
||||
title: "Profil"
|
||||
activity: "Etkinlik"
|
||||
no_activity: "Henüz onlarla etkinlik yok."
|
||||
unblock: "Engeli kaldır"
|
||||
block: "Engelle"
|
||||
blocked_blurb: "Engellendi — ödemeleri ve istekleri reddediliyor."
|
||||
block_blurb: "Engellemek, gelen ödeme ve isteklerini düşürür."
|
||||
settings:
|
||||
title: "Ayarlar"
|
||||
connected_nostr: "nostr'a bağlı"
|
||||
connecting_relays: "Relaylara bağlanılıyor…"
|
||||
identity: "Kimlik"
|
||||
copy_npub: "npub kopyala (genel)"
|
||||
rotate_key: "nostr anahtarını değiştir"
|
||||
import_identity: "Kimlik içe aktar (.backup / nsec)"
|
||||
backup_note: "Cihaz mı değiştiriyorsun? İKİSİNİ de yedekle: seed ifaden (bakiye) ve kimlik .backup dosyan (ad + anahtar)."
|
||||
wallet: "Cüzdan"
|
||||
display_unit: "Görüntüleme birimi"
|
||||
relays: "Relaylar"
|
||||
nostr_relays: "Nostr Relayları"
|
||||
node: "Düğüm"
|
||||
integrated_node: "Tümleşik düğüm ayarları"
|
||||
node_advanced: "Gelişmiş"
|
||||
slatepacks: "Slatepackler"
|
||||
slatepacks_value: "Manuel işlem"
|
||||
lock_wallet: "Cüzdanı kilitle"
|
||||
switch_wallet: "Cüzdan değiştir"
|
||||
advanced: "Gelişmiş"
|
||||
privacy: "Gizlilik"
|
||||
mixnet_routing: "Tor yönlendirme"
|
||||
messages_lookups: "Mesajlar ve aramalar"
|
||||
auto_accept: "Otomatik kabul"
|
||||
pairing: "Fiyat para birimi"
|
||||
accept_anyone: "Herkes"
|
||||
accept_contacts: "Yalnızca kişiler"
|
||||
accept_ask: "Her zaman sor"
|
||||
requests: "İstekler"
|
||||
incoming_requests: "Gelen istekler"
|
||||
incoming_requests_sub: "Başkalarının senden para istemesine izin ver"
|
||||
hide_amounts: "Tutarları gizle"
|
||||
hide_amounts_sub: "Bildirimlerde alınan tutarları gizle"
|
||||
language: "Dil"
|
||||
update_available: "Güncelleme mevcut"
|
||||
appearance: "Görünüm"
|
||||
theme: "Tema"
|
||||
theme_light: "Açık"
|
||||
theme_dark: "Koyu"
|
||||
theme_yellow: "Sarı"
|
||||
archive: "Arşiv"
|
||||
export_archive: "Arşivi dışa aktar"
|
||||
wipe_history: "Ödeme geçmişini sil"
|
||||
wipe_history_confirm: "Silmek için tekrar dokun — geri alınamaz"
|
||||
about: "Hakkında"
|
||||
goblin: "Goblin"
|
||||
build: "Sürüm %{build}"
|
||||
network: "Ağ"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Üçüncü taraf"
|
||||
grim: "GRIM (üst kaynak cüzdan)"
|
||||
grin_node: "Grin düğümü"
|
||||
sp_intro: "Gelişmiş — GRIM'in yaptığı gibi ham slatepackleri elle değiş tokuş et. Bunu yalnızca bir username üzerinden ödeme yapamadığında ya da alamadığında kullan."
|
||||
sp_receive_group: "Al ya da tamamla"
|
||||
sp_receive_blurb: "Birinin sana verdiği bir slatepack'i yapıştır. Goblin ödemeyi alır, faturayı öder ya da tamamlayıp zincire gönderir."
|
||||
sp_process: "Slatepack işle"
|
||||
sp_paste_first: "Önce bir slatepack yapıştır."
|
||||
sp_reply_ready: "Yanıt hazır — gönderene geri yolla."
|
||||
sp_finalizing: "Tamamlanıp zincire gönderiliyor…"
|
||||
sp_create_group: "Ödeme oluştur"
|
||||
sp_create_blurb: "Birine vermek için bir slatepack oluştur. Onlar alır, yanıtı geri gönderir, sen de yukarıda tamamlarsın."
|
||||
sp_amount_hint: "Grin cinsinden tutar"
|
||||
sp_addr_hint: "Alıcı adresi (isteğe bağlı)"
|
||||
sp_create: "Slatepack oluştur"
|
||||
sp_ready: "Slatepack hazır — alıcıya ver."
|
||||
sp_amount_gt_zero: "Sıfırdan büyük bir tutar gir."
|
||||
sp_to_send: "Gönderilecek slatepack"
|
||||
sp_copy: "Slatepack kopyala"
|
||||
rotate_line1: "• Tamamen yeni RASTGELE bir anahtar alırsın; eski npub artık almaz. Aralarında türetme zinciri yoktur."
|
||||
rotate_line2: "• Yeni anahtar tohumundan kurtarılamaz — anahtarı değiştirdikten hemen sonra yeni nsec'i yedekle."
|
||||
rotate_line3: "• Kullanıcı adın SERBEST BIRAKILIR — hemen ardından aynı adı ya da yeni bir ad al (serbest kaldığında başkası da kapabilir)."
|
||||
rotate_line4: "• Eski anahtara hâlâ yoldaki ödemeler KESİNTİYE uğrar — önce bekleyen ödemelerin bitmesini bekle."
|
||||
rotate_line5: "• npub'unu doğrudan kaydeden kişiler seni yeniden bulmalı — yeni npub'unu ya da yeniden aldığın username'i paylaş."
|
||||
cancel: "İptal"
|
||||
continue: "Devam"
|
||||
final_confirmation: "Son onay"
|
||||
rotate_confirm_blurb: "Bu işlem uygulamadan geri alınamaz. Değiştirmek için RESET yaz ve cüzdan parolanı gir."
|
||||
type_reset: "RESET yaz"
|
||||
wallet_password: "Cüzdan parolası"
|
||||
rotate_key_btn: "Anahtarı değiştir"
|
||||
rotating_key: "Anahtar değiştiriliyor…"
|
||||
key_rotated: "Anahtar değiştirildi"
|
||||
new_npub: "Yeni npub: %{npub}"
|
||||
backup_new_key: "YENİ gizli anahtarı şimdi yedekle — tohumun onu kurtaramaz."
|
||||
copy_new_nsec: "Yeni nsec yedeğini kopyala"
|
||||
done: "Bitti"
|
||||
rotation_failed: "Değiştirme başarısız"
|
||||
close: "Kapat"
|
||||
import_identity_title: "Kimlik içe aktar"
|
||||
import_blurb: "Bu cüzdanın nostr kimliğini değiştirir — bir GOBLIN .backup dosyası seç ya da nsec yapıştır. Yedek ayrıca kullanıcı adını ve geçmişini geri yükler. Hâlâ gerekiyorsa önce mevcut anahtarı yedekle."
|
||||
import_nsec_hint: "nsec1… veya yapıştırılan yedek"
|
||||
backup_password_hint: "Yedek parolası (yalnızca başka yerde dışa aktarıldıysa)"
|
||||
import_btn: "İçe aktar"
|
||||
importing: "İçe aktarılıyor…"
|
||||
identity_replaced: "Kimlik değiştirildi"
|
||||
now_using: "Şu an kullanılan: %{npub}"
|
||||
import_failed: "İçe aktarma başarısız"
|
||||
name_authority: "İsim otoritesi"
|
||||
name_authority_title: "İsim otoritesini değiştir"
|
||||
name_authority_blurb: "Adları kaydeden ve doğrulayan sunucu. Başka bir örneğe yönlendirerek oradaki adları kullan ve öde."
|
||||
name_authority_invalid: "Tam bir URL gir (https://…)."
|
||||
reset: "Sıfırla"
|
||||
save: "Kaydet"
|
||||
backup_file: "Dosyaya yedekle"
|
||||
choose_backup_file: "Bir .backup dosyası seç"
|
||||
backup_read_failed: "Dosya okunamadı."
|
||||
backup_saved: "Yedek kaydedildi"
|
||||
backup_saved_sub: ".backup dosyasını güvende tut — hem ona hem de parolana sahip olan kimliğini geri yükleyebilir."
|
||||
backup_file_title: "Kimliği yedekle"
|
||||
backup_file_blurb: "Kullanıcı adın ve anahtarınla tek bir şifreli .backup dosyası oluşturur. Mühürlemek için cüzdan parolanı gir."
|
||||
backup_write_failed: "Dosya kaydedilemedi."
|
||||
create_backup: "Yedek oluştur"
|
||||
registered: "%{name} kaydedildi"
|
||||
released_msg: "Bırakıldı — ad artık alınabilir"
|
||||
release_confirm: "%{name} bırakılsın mı?"
|
||||
release_blurb: "Serbest kalır kalmaz herkes alabilir — döndüğün bir sonraki anahtar dahil. 10 dakika boyunca başka bir kullanıcı adı kaydedemezsin."
|
||||
releasing: "Bırakılıyor…"
|
||||
keep_it: "Vazgeç"
|
||||
release_it: "Bırak"
|
||||
username: "Kullanıcı adı"
|
||||
username_note: "Adınız olarak gösterilir. goblin.st'de herkese açık. Ödemeler şifreli kalır."
|
||||
release_username: "Kullanıcı adını bırak"
|
||||
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
|
||||
working: "Çalışıyor…"
|
||||
claim: "Al"
|
||||
err_just_taken: "O kullanıcı adı az önce alındı"
|
||||
err_cooldown: "Yakın zamanda bir kullanıcı adı bıraktın — 10 dakika içinde yenisini kaydedebilirsin."
|
||||
err_unreachable: "goblin.st'ye ulaşılamadı — bağlantı sorunu. Tekrar dene."
|
||||
err_release: "Bırakılamadı: %{err}"
|
||||
avail_available: "Müsait!"
|
||||
avail_taken: "Alınmış"
|
||||
avail_reserved: "Ayrılmış"
|
||||
avail_invalid: "Adlar 3–20 karakter: a–z, 0–9, _ ya da -"
|
||||
avail_quarantined: "Müsait değil"
|
||||
avail_unknown: "Kontrol edilemedi — bağlantı sorunu. Tekrar dene."
|
||||
advanced:
|
||||
title: "Gelişmiş"
|
||||
intro: "GRIM'den düşük seviyeli cüzdan araçları. Bunlara normalde ihtiyacın olmaz."
|
||||
own_node_desc: "Herkese açık bir düğüme güvenmek yerine bu cihazda tam bir Grin düğümü senkronize edin."
|
||||
own_node_active: "Kendi düğümünüz çalışıyor"
|
||||
repair: "Cüzdanı onar"
|
||||
repair_desc: "Zinciri yeniden tara ve eksik çıktıları geri yükle. Bu biraz zaman alabilir."
|
||||
repair_unavailable: "Önce senkronize bir düğüm bağlantısı gerekir."
|
||||
repairing: "Onarılıyor… %{pct}%"
|
||||
restore: "Cüzdanı geri yükle"
|
||||
restore_desc: "Yerel verileri sil ve tohumundan yeniden oluştur. Onarım işe yaramadıysa bunu kullan — sonra cüzdanı yeniden açarsın."
|
||||
restore_confirm: "Geri yüklemek için tekrar dokun"
|
||||
show_phrase: "Kurtarma ifadesi"
|
||||
phrase_desc: "24 grin tohum kelimen — fonları kurtarmanın tek yolu. Onları çevrimdışı ve gizli tut."
|
||||
reveal: "İfadeyi göster"
|
||||
hide: "Gizle"
|
||||
password: "Cüzdan parolası"
|
||||
wrong_password: "Yanlış parola."
|
||||
delete: "Cüzdanı sil"
|
||||
delete_desc: "Bu cüzdanı bu cihazdan kalıcı olarak kaldır. Tohumun olmadan fonlar kurtarılamaz."
|
||||
delete_confirm: "Silmek için tekrar dokun"
|
||||
manage_node: "Düğüm bağlantısını yönet"
|
||||
repair_confirm: "Evet, şimdi onar"
|
||||
repair_confirm_note: "Onarım zinciri yeniden tarar ve birkaç dakika sürebilir."
|
||||
restore_confirm_note: "Bu, yerel verileri siler ve seed'inizden yeniden oluşturur — birkaç dakika sürebilir."
|
||||
nostr_key: "Nostr anahtarı"
|
||||
nostr_key_desc: "nsec'iniz, Nostr kimliğinizin gizli anahtarı. magick.market gibi Nostr uygulamalarında oturum açmak için kopyalayın veya QR kodunu gösterin. Ona sahip olan herkes kimliğinizi kontrol eder, gizli tutun."
|
||||
reveal_nsec: "Anahtarı göster"
|
||||
copy_nsec: "nsec'i kopyala"
|
||||
show_qr: "QR göster"
|
||||
hide_qr: "QR gizle"
|
||||
privacy:
|
||||
title: "Ağ gizliliği"
|
||||
intro: "Goblin özel trafiğini Tor üzerinden gönderir ve senin IP adresini relaydan gizler — şifreleme de gerisini gizler, böylece bir relay bir ödemeyi sana bağlayamaz."
|
||||
payments: "Ödemeler"
|
||||
payments_blurb: "Slatepack taşıyan her nostr mesajı."
|
||||
usernames: "usernamelar"
|
||||
usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları."
|
||||
price_avatars: "Fiyat"
|
||||
price_avatars_blurb: "Tutarların yanında gösterilen anlık kur."
|
||||
over_mixnet: "Tor üzerinden"
|
||||
direct_connection: "Doğrudan bağlantı"
|
||||
grin_node: "Grin düğümü"
|
||||
grin_node_blurb: "Blok eşitleme ve işlemini ağa yayma. Bu, herkes için aynı olan genel zincir verisidir ve kimliğinle ilişkilendirilmez."
|
||||
pairing:
|
||||
title: "Eşleştirme"
|
||||
intro: "Bakiyenin ve tutarların neye göre gösterildiği."
|
||||
pair_with: "Eşleştir"
|
||||
rates_note: "Kurlar yalnızca bir eşleştirme açıkken Tor üzerinden alınır — kapalıysa cihazından hiçbir kur isteği çıkmaz."
|
||||
relays:
|
||||
title: "Relaylar"
|
||||
intro: "Ödeme mesajları aşağıdaki her relay'e yansıtılır; almak için ulaşılabilir tek bir relay yeterlidir."
|
||||
your_relays: "Relaylarn"
|
||||
add_relay: "Relay ekle"
|
||||
add_relay_btn: "Relay ekle"
|
||||
save_reconnect: "Kaydet ve yeniden bağlan"
|
||||
none: "yok"
|
||||
count: "%{n} relay"
|
||||
node:
|
||||
title: "Düğüm"
|
||||
connection: "Bağlantı"
|
||||
integrated: "Tümleşik düğüm"
|
||||
applies_after: "Cüzdan kilitlenip yeniden açıldıktan sonra geçerli olur."
|
||||
add_external: "Harici düğüm ekle"
|
||||
api_secret_hint: "API gizli anahtarı (isteğe bağlı)"
|
||||
add_node: "Düğüm ekle"
|
||||
integrated_host: "tümleşik düğüm"
|
||||
summary_syncing: "%{conn} · eşitleniyor"
|
||||
summary_block: "Blok %{height} · %{conn}"
|
||||
nips:
|
||||
title: "nostr ve NIPler"
|
||||
intro1: "Goblin nostr konuşur — basit relay sunucuları üzerinden geçen imzalı mesajların açık bir protokolü. Cüzdanın kendi nostr kimliğini taşır: bağımsız rastgele bir anahtar, paran ve tohumundan kasıtlı olarak ayrı tutulur. Her ödeme, slatepack içinde olacak şekilde, kimlikler arasında uçtan uca şifreli bir doğrudan mesaj olarak gider."
|
||||
intro2: "goblin.st, Goblin'in ad servisidir: bir kullanıcı adı almak orada bir ad → anahtar eşlemesi yayımlar (NIP-05), böylece insanlar uzun bir npub yerine you'ya ödeme yapabilir. Kullanıcı adı herkese açıktır; ödeme içeriği asla değil. NIPler protokolün yapı taşlarıdır — özelliği okumak için birine dokun."
|
||||
n05_title: "Adlar"
|
||||
n05_blurb: "username@goblin.st'yi anahtarına eşler, böylece kullanıcı adları adres gibi çalışır."
|
||||
n17_title: "Özel mesajlar"
|
||||
n17_blurb: "Her ödemenin içinde gittiği şifreli DM zarfı."
|
||||
n44_title: "Şifreleme"
|
||||
n44_blurb: "Bu mesajların içinde kullanılan kimlik doğrulamalı şifreleme."
|
||||
n49_title: "Anahtar şifreleme"
|
||||
n49_blurb: "Gizli anahtarın parolanla kilitli olarak nasıl depolandığı."
|
||||
n59_title: "Hediye paketi"
|
||||
n59_blurb: "Mesajları sarar, böylece relaylar kimin kiminle konuştuğunu göremez."
|
||||
n98_title: "HTTP kimlik doğrulama"
|
||||
n98_blurb: "goblin.st'ye gönderilen kullanıcı adı kayıt isteğini imzalar."
|
||||
onboarding:
|
||||
intro:
|
||||
private_money_head: "Özel para"
|
||||
private_money_body: "Goblin, grin için bir cüzdan — zincirinde tutar ya da adres bulunmayan dijital nakit."
|
||||
send_like_message_head: "Mesaj gibi gönder"
|
||||
send_like_message_body: "Bir username ya da npub'a öde, nostr ve Tor üzerinden uçtan uca şifreli bir mesaj olarak ulaşır — aradaki hiç kimse tutarı ya da kimlerin dahil olduğunu göremez."
|
||||
yours_alone_head: "Yalnızca senin"
|
||||
yours_alone_body: "Anahtarlar, adlar ve geçmiş bu cihazda yaşar. GRIM cüzdanı üzerine kuruludur."
|
||||
get_started: "Başla"
|
||||
footnote: "Yaklaşık bir dakika sürer. Her şeyi sonradan değiştirebilirsin."
|
||||
node:
|
||||
kicker: "ADIM 1 / 3 · AĞ"
|
||||
title: "Goblin zinciri nasıl\nizlesin?"
|
||||
own_title: "Kendi düğümümü çalıştır"
|
||||
own_badge: "Özel"
|
||||
own_body: "Kimseye güvenmez — cüzdanın zinciri kendisi kontrol eder. Sen kurulumu bitirirken arka planda eşitlenir."
|
||||
connect_title: "Bir düğüme bağlan"
|
||||
connect_badge: "Anında"
|
||||
connect_body: "Eşitleme beklemesi yok. Seçtiğin düğüm cüzdanının sorgularını görebilir."
|
||||
changeable: "Ayarlar → Düğüm'den istediğin zaman değiştirilebilir."
|
||||
continue: "Devam"
|
||||
url_invalid: "Düğüm URL'si http:// ya da https:// ile başlamalı"
|
||||
wallet:
|
||||
kicker: "ADIM 2 / 3 · CÜZDAN"
|
||||
title: "Cüzdanını kur"
|
||||
create_new: "Yeni oluştur"
|
||||
restore_from_seed: "Tohumdan geri yükle"
|
||||
name_hint: "Cüzdan adı"
|
||||
password_hint: "Parola"
|
||||
repeat_password_hint: "Parolayı tekrarla"
|
||||
restore_hint: "Tohum kelimelerini hazır tut — onları sonra gireceksin."
|
||||
create_hint: "Sırada yazman için 24 tohum kelimesi var. Onlar paradır — onları elinde tutan paranı elinde tutar."
|
||||
continue: "Devam"
|
||||
passwords_no_match: "Parolalar eşleşmiyor"
|
||||
words:
|
||||
kicker: "ADIM 2 / 3 · CÜZDAN"
|
||||
title_restore: "Tohum kelimelerini gir"
|
||||
title_create: "Bu kelimeleri yaz"
|
||||
write_down_hint: "Kâğıda, sırayla. Bu kelimelere sahip olan paranı alabilir; onlar olmadan kaybolan bir cihaz kaybolan para demektir."
|
||||
paste: "Yapıştır"
|
||||
scan_qr: "QR tara"
|
||||
copy_clipboard: "Panoya kopyala (bundan kaçın)"
|
||||
restore_wallet: "Cüzdanı geri yükle"
|
||||
wrote_them_down: "Onları yazdım"
|
||||
fill_every_word: "Her kelimeyi doldur — düzenlemek için bir kelimeye dokun ya da ifadeyi yapıştır."
|
||||
confirm:
|
||||
kicker: "ADIM 2 / 3 · CÜZDAN"
|
||||
title: "Şimdi kanıtla"
|
||||
enter_hint: "Az önce yazdığın kelimeleri gir. Yazmak için bir kelimeye dokun."
|
||||
paste: "Yapıştır"
|
||||
create_wallet: "Cüzdan oluştur"
|
||||
keep_going: "Devam et — her kelime, sırayla."
|
||||
identity:
|
||||
kicker: "ADIM 3 / 3 · KİMLİK"
|
||||
title: "Ödeme kimliğin"
|
||||
key_being_made: "anahtar oluşturuluyor…"
|
||||
connected_nym: "Tor üzerinden bağlı"
|
||||
connecting_nym: "Tor üzerinden bağlanılıyor…"
|
||||
fresh_key_blurb: "Seed'inin parçası olmayan bir ödeme anahtarı — paranı hiç ellemeden istediğin an döndür."
|
||||
clean_slate_blurb: "Temiz bir sayfa mı istiyorsun? İstediğin zaman yepyeni bir anahtar tak — yeni sen eskisine bağlı değil. Aynı cüzdan, yeni yüz."
|
||||
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
|
||||
username_blurb: "Arkadaşların uzun bir anahtar yerine adına öder. İsteğe bağlı — istediğin an al."
|
||||
username_field_hint: "adınız"
|
||||
working: "Çalışıyor…"
|
||||
claim_username: "Kullanıcı adı al"
|
||||
available_when_connected: "Tor bağlandığında müsait — ya da atla ve sonra al."
|
||||
youre: "Sen %{name}'sin"
|
||||
claimed_title: "%{name} artık senin"
|
||||
claimed_blurb: "Arkadaşların artık sana adınla ödeme yapabilir. Her şey hazır — cüzdanını aç."
|
||||
open_wallet: "Cüzdanımı aç"
|
||||
skip_for_now: "Şimdilik atla"
|
||||
import_existing: "Zaten bir Goblin kimliğin var mı? İçe aktar"
|
||||
import_title: "Kimliğini içe aktar"
|
||||
import_blurb: "Bu yeni anahtar yerine mevcut anahtarını ve kullanıcı adını korumak için nsec'ini yapıştır veya bir .backup dosyası seç."
|
||||
errors:
|
||||
cant_open: "Cüzdan açılamadı: %{err}"
|
||||
cant_create: "Cüzdan oluşturulamadı: %{err}"
|
||||
send:
|
||||
scan_to_request: "İstemek için tara"
|
||||
scan_to_pay: "Ödemek için tara"
|
||||
tab_scan: "Tara"
|
||||
tab_my_code: "Kodum"
|
||||
request_from: "Şundan iste"
|
||||
send_to: "Şuna gönder"
|
||||
search_hint: "handle, npub ya da ad"
|
||||
suggested: "%{icon} Önerilen"
|
||||
no_contacts: "Henüz kişi yok. Birini handle ile bul."
|
||||
no_profile: "profil yok"
|
||||
tag_contact: "kişi"
|
||||
tag_on_nostr: "nostr'da"
|
||||
searching_nostr: "nostr aranıyor…"
|
||||
unverified_title: "Doğrulanmamış bir anahtara ödeme yapılsın mı?"
|
||||
unverified_body: "Bu anahtar için yayımlanmış bir nostr profili yok — yepyeni, anonim ya da yanlış yazılmış olabilir. Göndermeden önce doğru olduğunu iki kez kontrol et."
|
||||
keep_looking: "Aramaya devam et"
|
||||
pay_anyway: "Yine de öde"
|
||||
scan_not_recipient: "O QR bir goblin alıcısı değil — bir npub ya da handle bekleniyordu"
|
||||
scan_prompt: "Etkinleştirmek için bir goblin kodunu görüntüye getir"
|
||||
scan_to_pay_me: "Bana ödemek için tara"
|
||||
share_btn: "%{icon} Paylaş"
|
||||
share_message: "Goblin'de bana öde — %{handle}\n%{link}\nnpub: %{npub}"
|
||||
none_found: "%{label} için kimse bulunamadı"
|
||||
enter_recipient: "Bir handle, npub ya da ad gir"
|
||||
amount_title: "Tutar"
|
||||
to_name: "%{name} için"
|
||||
not_enough: "Yeterli grin yok"
|
||||
max: "Maks"
|
||||
note_label: "Not"
|
||||
note_hint: "Bir not ekle…"
|
||||
add_note: "Not ekle"
|
||||
edit_note: "Notu düzenle"
|
||||
note_cancel: "İptal"
|
||||
note_save: "Kaydet"
|
||||
review_btn: "İncele"
|
||||
confirm_request: "İsteği onayla"
|
||||
review_title: "İncele"
|
||||
requesting_from: "%{name} kişisinden isteniyor"
|
||||
youre_sending: "%{name} kişisine gönderiyorsun"
|
||||
row_from: "Gönderen"
|
||||
row_to: "Alıcı"
|
||||
row_note: "Not"
|
||||
row_proof: "Ödeme kanıtı"
|
||||
row_proof_val: "Dahil"
|
||||
row_proof_shared: "Kanıt paylaşılır"
|
||||
row_they_pay: "Onlar öder"
|
||||
row_they_pay_val: "Yalnızca onaylarlarsa"
|
||||
row_delivery: "Teslimat"
|
||||
row_delivery_val: "NIP-44 şifreli, Tor üzerinden"
|
||||
row_network_fee: "Ağ ücreti"
|
||||
row_network_fee_val: "Bakiyenden düşülür"
|
||||
row_privacy: "Gizlilik"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "İstek gönder"
|
||||
request_approve_hint: "Onaylamaları için bir istek alacaklar"
|
||||
hold_to_send: "Göndermek için basılı tut"
|
||||
lower_amount: "Geri dön ve tutarı düşür"
|
||||
hold_confirm_hint: "Onaylamak için basılı tut"
|
||||
requesting: "İsteniyor…"
|
||||
sending: "Gönderiliyor…"
|
||||
they: "Onlar"
|
||||
request_blocked: "%{who} istek kabul etmiyor. Bunun yerine sana grin göndermesini iste."
|
||||
failed_request_title: "İstenemedi"
|
||||
failed_send_title: "Gönderilemedi"
|
||||
failed_request_body: "İsteği teslim edemedik. Bunun yerine sana grin göndermesini iste."
|
||||
failed_send_body: "Ödeme teslim edilemedi. Grin'in güvende — tekrar dene."
|
||||
try_again_btn: "Tekrar dene"
|
||||
close_btn: "Kapat"
|
||||
success:
|
||||
requested: "İstendi"
|
||||
sent: "Gönderildi"
|
||||
from: "şuradan"
|
||||
to: "şuraya"
|
||||
subtitle: "%{dir} %{who} · az önce"
|
||||
done_btn: "Bitti"
|
||||
receipt_btn: "Makbuz"
|
||||
@@ -44,7 +44,7 @@ wallets:
|
||||
await_fin_amount: 等待确定中
|
||||
locked_amount: 锁定帐户
|
||||
txs_empty: '手动接收资金或通过传输接收资金 %{message} or %{transport} 更改钱包设置, 请按屏幕底部的按钮 %{settings} 按钮.'
|
||||
title: 钱包
|
||||
title: Goblin
|
||||
create_desc: 创建或种子单词导入已有钱包.
|
||||
add: 添加钱包
|
||||
name: '用户名:'
|
||||
@@ -355,4 +355,478 @@ keyboard:
|
||||
m: 一
|
||||
m1: ','
|
||||
m2: .
|
||||
m3: /
|
||||
m3: /
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "匿名"
|
||||
connected_nym: "已通过 Tor 连接"
|
||||
nym_ready: "Tor 就绪 · 连接中继…"
|
||||
connecting_nym: "正在连接 Tor…"
|
||||
cant_reach_node: "无法连接节点"
|
||||
node_synced: "节点已同步"
|
||||
syncing: "同步中…"
|
||||
balance_updating: "余额更新中…"
|
||||
balance_stale: "无法连接节点 · 上次已知余额"
|
||||
fiat_unavailable: "汇率不可用"
|
||||
listening: "正在监听付款"
|
||||
block: "区块 %{height}"
|
||||
waiting_for_chain: "等待链数据…"
|
||||
nav_wallet: "钱包"
|
||||
nav_pay: "支付"
|
||||
nav_activity: "动态"
|
||||
nav_receive: "收款"
|
||||
nav_settings: "设置"
|
||||
activity: "动态"
|
||||
news: "新闻"
|
||||
empty_title: "暂无动态"
|
||||
empty_sub: "收发 grin 即可开始。"
|
||||
recent: "最近"
|
||||
scan_to_pay: "扫码支付"
|
||||
type_amount: "输入金额"
|
||||
request: "请求"
|
||||
pay: "支付"
|
||||
enter_amount: "输入要支付或请求的金额"
|
||||
activity:
|
||||
canceled: "已取消"
|
||||
pending: "待处理"
|
||||
earlier: "更早"
|
||||
today: "今天"
|
||||
yesterday: "昨天"
|
||||
title: "动态"
|
||||
requests: "请求"
|
||||
empty_title: "暂无动态"
|
||||
empty_sub: "你的付款将显示在这里。"
|
||||
pending_header: "待处理"
|
||||
receipt:
|
||||
title: "收据"
|
||||
not_found: "未找到交易"
|
||||
for_note: "用于 %{note}"
|
||||
details: "交易详情"
|
||||
canceled: "已取消"
|
||||
expired: "已过期"
|
||||
funds_returned: "资金已退回"
|
||||
complete: "已完成"
|
||||
payment_received: "已收到付款"
|
||||
payment_sent: "付款发送成功"
|
||||
pending: "待处理"
|
||||
confs: "%{c}/%{r} 次确认"
|
||||
waiting_to_confirm: "等待确认"
|
||||
paying: "支付中…"
|
||||
you: "你"
|
||||
to: "收款方"
|
||||
from: "付款方"
|
||||
nostr: "nostr"
|
||||
fee_none: "无"
|
||||
network_fee: "网络费用"
|
||||
privacy: "隐私"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "交易"
|
||||
cancel_request: "取消请求"
|
||||
cancel_send: "取消付款"
|
||||
cancel_send_confirm: "再次点按以取消 — 对方可能仍会收到"
|
||||
cancel_send_done: "付款已取消 — 你的资金已重新可用"
|
||||
cancel_send_too_late: "这笔付款已经完成,无法取消"
|
||||
waiting_to_receive: "等待 %{name} 接收…"
|
||||
request:
|
||||
title: "%{name} 发起请求"
|
||||
approve: "同意"
|
||||
decline: "拒绝"
|
||||
review_title: "审核请求"
|
||||
hold_to_accept: "按住以接受"
|
||||
hold_accept_hint: "按住以支付此请求"
|
||||
receive:
|
||||
title: "收款"
|
||||
requesting: "正在请求 %{amt}%{tsu} — 分享以收款"
|
||||
clear_request: "清除请求"
|
||||
share_handle: "分享你的用户名以收款"
|
||||
share_npub: "分享你的 npub 以收款"
|
||||
copied: "已复制"
|
||||
copy_nostr_id: "复制 nostr ID"
|
||||
copy_address: "复制地址"
|
||||
copy_npub: "复制 npub"
|
||||
share_message: "在 Goblin 上向我付款 (goblin.st) — %{npub}"
|
||||
privacy_note: "你的用户名是公开的。付款内容在网络中保持加密。"
|
||||
privacy_note_npub: "你的 npub 是公开的。付款内容在网络中保持加密。"
|
||||
profile:
|
||||
title: "资料"
|
||||
activity: "动态"
|
||||
no_activity: "尚无往来记录。"
|
||||
unblock: "取消屏蔽"
|
||||
block: "屏蔽"
|
||||
blocked_blurb: "已屏蔽 — 其付款和请求会被丢弃。"
|
||||
block_blurb: "屏蔽后将丢弃对方发来的付款和请求。"
|
||||
settings:
|
||||
title: "设置"
|
||||
connected_nostr: "已连接 nostr"
|
||||
connecting_relays: "正在连接中继…"
|
||||
identity: "身份"
|
||||
copy_npub: "复制 npub(公开)"
|
||||
rotate_key: "轮换 nostr 密钥"
|
||||
import_identity: "导入身份(.backup / nsec)"
|
||||
backup_note: "更换设备?两者都要备份:你的助记词(资金)和身份 .backup 文件(名称 + 密钥)。"
|
||||
wallet: "钱包"
|
||||
display_unit: "显示单位"
|
||||
relays: "中继"
|
||||
nostr_relays: "Nostr 中继"
|
||||
node: "节点"
|
||||
integrated_node: "集成节点设置"
|
||||
node_advanced: "高级"
|
||||
slatepacks: "Slatepack"
|
||||
slatepacks_value: "手动交易"
|
||||
lock_wallet: "锁定钱包"
|
||||
switch_wallet: "切换钱包"
|
||||
advanced: "高级"
|
||||
privacy: "隐私"
|
||||
mixnet_routing: "Tor 路由"
|
||||
messages_lookups: "消息和查询"
|
||||
auto_accept: "自动接受"
|
||||
pairing: "价格货币"
|
||||
accept_anyone: "任何人"
|
||||
accept_contacts: "仅联系人"
|
||||
accept_ask: "每次询问"
|
||||
requests: "请求"
|
||||
incoming_requests: "收到的请求"
|
||||
incoming_requests_sub: "允许他人向你请求付款"
|
||||
hide_amounts: "隐藏金额"
|
||||
hide_amounts_sub: "在通知中隐藏收到的金额"
|
||||
language: "语言"
|
||||
update_available: "有可用更新"
|
||||
appearance: "外观"
|
||||
theme: "主题"
|
||||
theme_light: "浅色"
|
||||
theme_dark: "深色"
|
||||
theme_yellow: "黄色"
|
||||
archive: "存档"
|
||||
export_archive: "导出存档"
|
||||
wipe_history: "清除付款记录"
|
||||
wipe_history_confirm: "再次点按以清除 — 无法撤销"
|
||||
about: "关于"
|
||||
goblin: "Goblin"
|
||||
build: "构建 %{build}"
|
||||
network: "网络"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "第三方"
|
||||
grim: "GRIM(上游钱包)"
|
||||
grin_node: "Grin 节点"
|
||||
sp_intro: "高级功能 — 像 GRIM 那样手动交换原始 slatepack。仅在无法通过 username 收付款时使用。"
|
||||
sp_receive_group: "接收或确认"
|
||||
sp_receive_blurb: "粘贴别人给你的 slatepack。Goblin 会接收付款、支付账单,或确认并广播到链上。"
|
||||
sp_process: "处理 slatepack"
|
||||
sp_paste_first: "请先粘贴 slatepack。"
|
||||
sp_reply_ready: "回复已就绪 — 发回给发送方。"
|
||||
sp_finalizing: "正在确认并广播到链上…"
|
||||
sp_create_group: "创建付款"
|
||||
sp_create_blurb: "生成一个 slatepack 交给他人。对方接收后将回复发回,你在上方确认即可。"
|
||||
sp_amount_hint: "金额(grin)"
|
||||
sp_addr_hint: "收款地址(可选)"
|
||||
sp_create: "创建 slatepack"
|
||||
sp_ready: "slatepack 已就绪 — 交给收款方。"
|
||||
sp_amount_gt_zero: "请输入大于零的金额。"
|
||||
sp_to_send: "待发送的 slatepack"
|
||||
sp_copy: "复制 slatepack"
|
||||
rotate_line1: "• 你会得到一个全新的随机密钥;旧 npub 将停止接收。两者之间没有任何派生关系。"
|
||||
rotate_line2: "• 新密钥无法从助记词恢复 — 轮换后请立即备份新的 nsec。"
|
||||
rotate_line3: "• 你的用户名将被释放——请立即认领相同或新的名称(一旦释放,他人也可抢注)。"
|
||||
rotate_line4: "• 正在发往旧密钥的付款将受影响 — 请先等待待处理付款完成。"
|
||||
rotate_line5: "• 直接保存了你 npub 的联系人需要重新查找你 — 分享你的新 npub 或重新注册的 username。"
|
||||
cancel: "取消"
|
||||
continue: "继续"
|
||||
final_confirmation: "最终确认"
|
||||
rotate_confirm_blurb: "此操作在应用内无法撤销。输入 RESET 并输入钱包密码以进行轮换。"
|
||||
type_reset: "输入 RESET"
|
||||
wallet_password: "钱包密码"
|
||||
rotate_key_btn: "轮换密钥"
|
||||
rotating_key: "正在轮换密钥…"
|
||||
key_rotated: "密钥已轮换"
|
||||
new_npub: "新 npub:%{npub}"
|
||||
backup_new_key: "立即备份新私钥 — 助记词无法恢复它。"
|
||||
copy_new_nsec: "复制新 nsec 备份"
|
||||
done: "完成"
|
||||
rotation_failed: "轮换失败"
|
||||
close: "关闭"
|
||||
import_identity_title: "导入身份"
|
||||
import_blurb: "替换此钱包的 nostr 身份——选择一个 GOBLIN .backup 文件,或粘贴 nsec。备份也会恢复你的用户名和历史。如果仍需要当前密钥,请先备份。"
|
||||
import_nsec_hint: "nsec1… 或粘贴的备份"
|
||||
backup_password_hint: "备份密码(仅当在他处导出时需要)"
|
||||
import_btn: "导入"
|
||||
importing: "正在导入…"
|
||||
identity_replaced: "身份已替换"
|
||||
now_using: "当前使用:%{npub}"
|
||||
import_failed: "导入失败"
|
||||
name_authority: "名称授权方"
|
||||
name_authority_title: "更改名称授权方"
|
||||
name_authority_blurb: "注册和验证名称的服务器。指向另一个实例即可使用并支付那里托管的名称。"
|
||||
name_authority_invalid: "请输入完整网址(https://…)。"
|
||||
reset: "重置"
|
||||
save: "保存"
|
||||
backup_file: "备份到文件"
|
||||
choose_backup_file: "选择 .backup 文件"
|
||||
backup_read_failed: "无法读取该文件。"
|
||||
backup_saved: "备份已保存"
|
||||
backup_saved_sub: "妥善保管 .backup 文件——同时拥有它和你密码的人都能恢复你的身份。"
|
||||
backup_file_title: "备份身份"
|
||||
backup_file_blurb: "创建一个包含你的用户名和密钥的加密 .backup 文件。输入钱包密码以封存它。"
|
||||
backup_write_failed: "无法保存文件。"
|
||||
create_backup: "创建备份"
|
||||
registered: "已注册 %{name}"
|
||||
released_msg: "已释放 — 用户名可被抢注"
|
||||
release_confirm: "释放 %{name}?"
|
||||
release_blurb: "一旦释放即可被认领——任何人都可以,包括你接下来轮换到的密钥。10 分钟内你无法注册另一个用户名。"
|
||||
releasing: "正在释放…"
|
||||
keep_it: "保留"
|
||||
release_it: "释放"
|
||||
username: "用户名"
|
||||
username_note: "显示为你的名字。在 goblin.st 上公开。付款保持加密。"
|
||||
release_username: "释放用户名"
|
||||
pick_username: "选择用户名 — 可选"
|
||||
working: "处理中…"
|
||||
claim: "注册"
|
||||
err_just_taken: "该用户名刚被占用"
|
||||
err_cooldown: "你刚释放了一个用户名 — 10 分钟内无法注册新用户名。"
|
||||
err_unreachable: "无法连接 goblin.st — 连接中断。请重试。"
|
||||
err_release: "无法释放:%{err}"
|
||||
avail_available: "可用!"
|
||||
avail_taken: "已被占用"
|
||||
avail_reserved: "已保留"
|
||||
avail_invalid: "用户名为 3–20 个字符:a–z、0–9、_ 或 -"
|
||||
avail_quarantined: "不可用"
|
||||
avail_unknown: "无法检查 — 连接中断。请重试。"
|
||||
advanced:
|
||||
title: "高级"
|
||||
intro: "来自 GRIM 的底层钱包工具。通常你用不到这些。"
|
||||
own_node_desc: "在本设备上同步完整的 Grin 节点,而不是信任公共节点。"
|
||||
own_node_active: "正在运行你自己的节点"
|
||||
repair: "修复钱包"
|
||||
repair_desc: "重新扫描链并恢复任何缺失的输出。这可能需要一些时间。"
|
||||
repair_unavailable: "需要先有已同步的节点连接。"
|
||||
repairing: "修复中… %{pct}%"
|
||||
restore: "恢复钱包"
|
||||
restore_desc: "删除本地数据并从助记词重建。如果修复无效,请使用此功能 — 之后需重新打开钱包。"
|
||||
restore_confirm: "再次点击以恢复"
|
||||
show_phrase: "恢复助记词"
|
||||
phrase_desc: "你的 24 个 grin 助记词 — 恢复资金的唯一方式。请离线且私密保存。"
|
||||
reveal: "显示助记词"
|
||||
hide: "隐藏"
|
||||
password: "钱包密码"
|
||||
wrong_password: "密码错误。"
|
||||
delete: "删除钱包"
|
||||
delete_desc: "从此设备永久移除该钱包。没有助记词,资金将无法找回。"
|
||||
delete_confirm: "再次点击以删除"
|
||||
manage_node: "管理节点连接"
|
||||
repair_confirm: "是的,立即修复"
|
||||
repair_confirm_note: "修复会重新扫描链,可能需要几分钟。"
|
||||
restore_confirm_note: "这会清除本地数据并从助记词重建——可能需要几分钟。"
|
||||
nostr_key: "Nostr 密钥"
|
||||
nostr_key_desc: "您的 nsec,即 Nostr 身份的私钥。复制它或显示二维码,即可登录 magick.market 等 Nostr 应用。持有它的人即可控制您的身份,请妥善保管。"
|
||||
reveal_nsec: "显示密钥"
|
||||
copy_nsec: "复制 nsec"
|
||||
show_qr: "显示二维码"
|
||||
hide_qr: "隐藏二维码"
|
||||
privacy:
|
||||
title: "网络隐私"
|
||||
intro: "Goblin 通过 Tor 发送其私密流量,向中继隐藏你的 IP — 加密隐藏其余部分,使中继无法将付款关联到你。"
|
||||
payments: "付款"
|
||||
payments_blurb: "每条携带 slatepack 的 nostr 消息。"
|
||||
usernames: "用户名"
|
||||
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
|
||||
price_avatars: "价格"
|
||||
price_avatars_blurb: "金额旁显示的实时法币汇率。"
|
||||
over_mixnet: "经由 Tor"
|
||||
direct_connection: "直接连接"
|
||||
grin_node: "Grin 节点"
|
||||
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
|
||||
pairing:
|
||||
title: "配对"
|
||||
intro: "你的余额和金额以何种货币显示。"
|
||||
pair_with: "配对货币"
|
||||
rates_note: "汇率仅在开启配对时通过 Tor 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
||||
relays:
|
||||
title: "中继"
|
||||
intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。"
|
||||
your_relays: "你的中继"
|
||||
add_relay: "添加中继"
|
||||
add_relay_btn: "添加中继"
|
||||
save_reconnect: "保存并重新连接"
|
||||
none: "无"
|
||||
count: "%{n} 个中继"
|
||||
node:
|
||||
title: "节点"
|
||||
connection: "连接"
|
||||
integrated: "集成节点"
|
||||
applies_after: "在钱包锁定并再次解锁后生效。"
|
||||
add_external: "添加外部节点"
|
||||
api_secret_hint: "API 密钥(可选)"
|
||||
add_node: "添加节点"
|
||||
integrated_host: "集成节点"
|
||||
summary_syncing: "%{conn} · 同步中"
|
||||
summary_block: "区块 %{height} · %{conn}"
|
||||
nips:
|
||||
title: "nostr 与 NIPs"
|
||||
intro1: "Goblin 使用 nostr — 一种通过简单中继服务器传递签名消息的开放协议。你的钱包拥有自己的 nostr 身份:一个独立的随机密钥,刻意与你的资金和助记词保持独立。每笔付款都作为身份之间的端到端加密私信传输,slatepack 就包含在其中。"
|
||||
intro2: "goblin.st 是 Goblin 的名称服务:注册用户名会在此发布名称 → 密钥的映射(NIP-05),让人们可以付款给 you 而不必使用冗长的 npub。用户名是公开的;付款内容则永不公开。NIPs 是该协议的构建模块 — 点击任意一项可阅读规范。"
|
||||
n05_title: "名称"
|
||||
n05_blurb: "将 username@goblin.st 映射到你的密钥,让用户名像地址一样使用。"
|
||||
n17_title: "私密消息"
|
||||
n17_blurb: "每笔付款传输所用的加密私信信封。"
|
||||
n44_title: "加密"
|
||||
n44_blurb: "这些消息内部使用的认证加密算法。"
|
||||
n49_title: "密钥加密"
|
||||
n49_blurb: "私钥静态存储的方式,由你的密码锁定。"
|
||||
n59_title: "礼物包装"
|
||||
n59_blurb: "包装消息,使中继无法看到通信双方是谁。"
|
||||
n98_title: "HTTP 认证"
|
||||
n98_blurb: "为向 goblin.st 注册用户名的请求签名。"
|
||||
onboarding:
|
||||
intro:
|
||||
private_money_head: "私密货币"
|
||||
private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。"
|
||||
send_like_message_head: "像发消息一样付款"
|
||||
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Tor 送达 — 中间任何人都看不到金额或参与者。"
|
||||
yours_alone_head: "完全属于你"
|
||||
yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。"
|
||||
get_started: "开始使用"
|
||||
footnote: "约需一分钟。之后一切均可更改。"
|
||||
node:
|
||||
kicker: "步骤 1 / 3 · 网络"
|
||||
title: "Goblin 该如何\n监视链?"
|
||||
own_title: "运行我自己的节点"
|
||||
own_badge: "私密"
|
||||
own_body: "无需信任任何人 — 钱包自行验证链。完成设置时在后台同步。"
|
||||
connect_title: "连接到节点"
|
||||
connect_badge: "即时"
|
||||
connect_body: "无需等待同步。你选择的节点可看到钱包的查询。"
|
||||
changeable: "随时可在 设置 → 节点 中更改。"
|
||||
continue: "继续"
|
||||
url_invalid: "节点 URL 必须以 http:// 或 https:// 开头"
|
||||
wallet:
|
||||
kicker: "步骤 2 / 3 · 钱包"
|
||||
title: "设置你的钱包"
|
||||
create_new: "新建"
|
||||
restore_from_seed: "从助记词恢复"
|
||||
name_hint: "钱包名称"
|
||||
password_hint: "密码"
|
||||
repeat_password_hint: "重复密码"
|
||||
restore_hint: "准备好你的助记词 — 下一步将输入。"
|
||||
create_hint: "接下来你会得到 24 个助记词以供抄写。它们就是钱 — 谁持有它们,谁就掌握你的资金。"
|
||||
continue: "继续"
|
||||
passwords_no_match: "密码不一致"
|
||||
words:
|
||||
kicker: "步骤 2 / 3 · 钱包"
|
||||
title_restore: "输入你的助记词"
|
||||
title_create: "抄下这些词"
|
||||
write_down_hint: "按顺序写在纸上。任何持有这些词的人都能取走你的资金;丢失这些词又丢失设备,资金将无法找回。"
|
||||
paste: "粘贴"
|
||||
scan_qr: "扫描二维码"
|
||||
copy_clipboard: "复制到剪贴板(不建议)"
|
||||
restore_wallet: "恢复钱包"
|
||||
wrote_them_down: "我已抄好"
|
||||
fill_every_word: "填写每个词 — 点击某个词进行编辑,或粘贴整个短语。"
|
||||
confirm:
|
||||
kicker: "步骤 2 / 3 · 钱包"
|
||||
title: "现在来验证"
|
||||
enter_hint: "输入你刚抄下的词。点击某个词进行输入。"
|
||||
paste: "粘贴"
|
||||
create_wallet: "创建钱包"
|
||||
keep_going: "继续 — 每个词,按顺序。"
|
||||
identity:
|
||||
kicker: "步骤 3 / 3 · 身份"
|
||||
title: "你的付款身份"
|
||||
key_being_made: "正在生成密钥…"
|
||||
connected_nym: "已通过 Tor 连接"
|
||||
connecting_nym: "正在通过 Tor 连接…"
|
||||
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
|
||||
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
|
||||
pick_username: "选择用户名 — 可选"
|
||||
username_blurb: "朋友支付给你的名称,而不是一长串密钥。可选——随时认领。"
|
||||
username_field_hint: "你的用户名"
|
||||
working: "处理中…"
|
||||
claim_username: "注册用户名"
|
||||
available_when_connected: "Tor 连接后可用 — 或跳过,稍后注册。"
|
||||
youre: "你是 %{name}"
|
||||
claimed_title: "%{name} 已归你所有"
|
||||
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
|
||||
open_wallet: "打开我的钱包"
|
||||
skip_for_now: "暂时跳过"
|
||||
import_existing: "已有 Goblin 身份?导入它"
|
||||
import_title: "导入你的身份"
|
||||
import_blurb: "粘贴你的 nsec 或选择一个 .backup 文件,保留你现有的密钥和用户名,而不是这个新的。"
|
||||
errors:
|
||||
cant_open: "无法打开钱包:%{err}"
|
||||
cant_create: "无法创建钱包:%{err}"
|
||||
send:
|
||||
scan_to_request: "扫码请求"
|
||||
scan_to_pay: "扫码支付"
|
||||
tab_scan: "扫描"
|
||||
tab_my_code: "我的二维码"
|
||||
request_from: "向谁请求"
|
||||
send_to: "发送给"
|
||||
search_hint: "handle、npub 或名称"
|
||||
suggested: "%{icon} 建议"
|
||||
no_contacts: "暂无联系人。通过 handle 查找某人。"
|
||||
no_profile: "无资料"
|
||||
tag_contact: "联系人"
|
||||
tag_on_nostr: "在 nostr 上"
|
||||
searching_nostr: "正在搜索 nostr…"
|
||||
unverified_title: "向未验证的密钥付款?"
|
||||
unverified_body: "此密钥未发布 nostr 资料 — 它可能是全新的、匿名的或输错的。发送前请仔细核对是否正确。"
|
||||
keep_looking: "继续查找"
|
||||
pay_anyway: "仍然付款"
|
||||
scan_not_recipient: "该二维码不是 goblin 收款方 — 应为 npub 或 handle"
|
||||
scan_prompt: "将 goblin 二维码对准取景框以激活"
|
||||
scan_to_pay_me: "扫码向我付款"
|
||||
share_btn: "%{icon} 分享"
|
||||
share_message: "在 Goblin 上向我付款 — %{handle}\n%{link}\nnpub:%{npub}"
|
||||
none_found: "未找到与 %{label} 匹配的人"
|
||||
enter_recipient: "输入 handle、npub 或名称"
|
||||
amount_title: "金额"
|
||||
to_name: "发送给 %{name}"
|
||||
not_enough: "你的 grin 余额不足"
|
||||
max: "最大"
|
||||
note_label: "备注"
|
||||
note_hint: "添加备注…"
|
||||
add_note: "添加备注"
|
||||
edit_note: "编辑备注"
|
||||
note_cancel: "取消"
|
||||
note_save: "保存"
|
||||
review_btn: "查看"
|
||||
confirm_request: "确认请求"
|
||||
review_title: "查看"
|
||||
requesting_from: "向 %{name} 请求"
|
||||
youre_sending: "你正在发送给 %{name}"
|
||||
row_from: "付款方"
|
||||
row_to: "收款方"
|
||||
row_note: "备注"
|
||||
row_proof: "支付证明"
|
||||
row_proof_val: "已包含"
|
||||
row_proof_shared: "证明分享给"
|
||||
row_they_pay: "对方支付"
|
||||
row_they_pay_val: "仅当对方同意时"
|
||||
row_delivery: "传输"
|
||||
row_delivery_val: "NIP-44 加密,经由 Tor"
|
||||
row_network_fee: "网络费用"
|
||||
row_network_fee_val: "从你的余额中扣除"
|
||||
row_privacy: "隐私"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "发送请求"
|
||||
request_approve_hint: "对方将收到一条待同意的请求"
|
||||
hold_to_send: "长按发送"
|
||||
lower_amount: "返回并降低金额"
|
||||
hold_confirm_hint: "按住以确认"
|
||||
requesting: "正在请求…"
|
||||
sending: "正在发送…"
|
||||
they: "对方"
|
||||
request_blocked: "%{who} 不接受请求。请对方改为向你发送 grin。"
|
||||
failed_request_title: "请求失败"
|
||||
failed_send_title: "发送失败"
|
||||
failed_request_body: "我们无法送达请求。请对方改为向你发送 grin。"
|
||||
failed_send_body: "付款未送达。你的 grin 是安全的 — 请重试。"
|
||||
try_again_btn: "重试"
|
||||
close_btn: "关闭"
|
||||
success:
|
||||
requested: "已请求"
|
||||
sent: "已发送"
|
||||
from: "来自"
|
||||
to: "发往"
|
||||
subtitle: "%{dir} %{who} · 刚刚"
|
||||
done_btn: "完成"
|
||||
receipt_btn: "收据"
|
||||
@@ -57,6 +57,19 @@
|
||||
<string>Document</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>st.goblin.pay</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>goblin</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.finance</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Resources/AppIcon.icns</key>
|
||||
<data>
|
||||
F0XBdu5xI+eXrj78HQf2Qr9SKio=
|
||||
</data>
|
||||
</dict>
|
||||
<key>files2</key>
|
||||
<dict>
|
||||
<key>Resources/AppIcon.icns</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
ZjAn1LaNzSeTeUtKbWKWE7W2ELzhYyrHjKYOXUkQvcI=
|
||||
</data>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -54,14 +54,14 @@ 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.
|
||||
# The Tor transport (embedded arti) is linked INTO libgrim.so, so there is no
|
||||
# separate sidecar binary to cross-build or bundle into jniLibs.
|
||||
}
|
||||
|
||||
### Build application
|
||||
function build_apk() {
|
||||
flavor=$3
|
||||
[[ flavor == "" ]] && flavor="local"
|
||||
[[ -z "$flavor" ]] && flavor="local"
|
||||
cd android || exit 1
|
||||
./gradlew clean
|
||||
# Build signed apk if keystore exists
|
||||
@@ -80,12 +80,15 @@ function build_apk() {
|
||||
fi
|
||||
|
||||
if [[ $1 == "" ]] && [ $success -eq 1 ]; then
|
||||
# Launch application at all connected devices.
|
||||
# Launch application at all connected devices. The installed application id
|
||||
# (st.goblin.wallet) differs from the Java namespace (mw.gri.android), so
|
||||
# derive it from build.gradle and launch the fully-qualified activity.
|
||||
app_id=$(grep -m 1 -Po 'applicationId "\K[^"]*' app/build.gradle)
|
||||
for SERIAL in $(adb devices | grep -v List | cut -f 1);
|
||||
do
|
||||
adb -s "$SERIAL" install ${apk_path}
|
||||
sleep 1s
|
||||
adb -s "$SERIAL" shell am start -n mw.gri.android/.MainActivity;
|
||||
adb -s "$SERIAL" shell am start -n "${app_id}/mw.gri.android.MainActivity";
|
||||
done
|
||||
elif [ $success -eq 1 ]; then
|
||||
# Get version
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
#!/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).
|
||||
# Regenerate EVERY platform's app icon from two canonical sources:
|
||||
# img/goblin-icon.png the gradient app icon (yellow gradient + black mascot)
|
||||
# img/goblin-mark-black.svg the black mascot mark, vector, transparent bg
|
||||
# (mirror of site/assets/goblin-mark-black.svg)
|
||||
#
|
||||
# Square icons (desktop window, Linux AppImage, Android launcher, Windows .ico,
|
||||
# macOS .icns) come from the gradient PNG. The Android *adaptive* foreground is
|
||||
# the black mascot on transparency, composited by the OS over the yellow
|
||||
# background color (res/values/ic_launcher_background.xml = #FFD60A) — which
|
||||
# reproduces the gradient icon's look.
|
||||
#
|
||||
# Requires ImageMagick (magick) and python3 (for the .icns container).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
ICON=img/goblin-icon.png
|
||||
MASK=img/goblin-mask.png
|
||||
MARK=img/goblin-mark-black.svg
|
||||
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
|
||||
# --- Desktop window icon (egui, src/main.rs) + Linux AppImage AppDir icon ---
|
||||
magick "$ICON" -resize 256x256 PNG32:img/icon.png
|
||||
cp img/icon.png linux/Goblin.AppDir/goblin.png
|
||||
|
||||
# Android launcher icons.
|
||||
declare -A SIZES=( [mdpi]=48 [hdpi]=72 [xhdpi]=96 [xxhdpi]=144 [xxxhdpi]=192 )
|
||||
# --- Android launcher icons (gradient square) + adaptive foreground (mascot) ---
|
||||
declare -A SIZES=( [mdpi]=48 [hdpi]=72 [xhdpi]=96 [xxhdpi]=144 [xxxhdpi]=192 )
|
||||
declare -A FG_SIZES=( [mdpi]=108 [hdpi]=162 [xhdpi]=216 [xxhdpi]=324 [xxxhdpi]=432 )
|
||||
for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
|
||||
s=${SIZES[$d]}; fg=${FG_SIZES[$d]}
|
||||
# mascot 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"
|
||||
# Mascot fills ~60% of the adaptive canvas — inside the ~61% safe zone, so no
|
||||
# launcher mask (circle/squircle) ever clips it.
|
||||
art=$(( fg * 60 / 100 ))
|
||||
magick "$ICON" -resize "${s}x${s}" PNG32:"$RES/mipmap-$d/ic_launcher.png"
|
||||
magick "$ICON" -resize "${s}x${s}" PNG32:"$RES/mipmap-$d/ic_launcher_round.png"
|
||||
magick -background none "$MARK" -resize "${art}x${art}" \
|
||||
-gravity center -extent "${fg}x${fg}" PNG32:"$RES/mipmap-$d/ic_launcher_foreground.png"
|
||||
done
|
||||
|
||||
echo "icons generated"
|
||||
# --- Android notification (status-bar) icon: white-on-transparent mascot ---
|
||||
# Android renders ic_stat_name as an alpha-only silhouette, so the RGB channels
|
||||
# are forced to pure white; ~90% of the canvas matches the old asset's padding.
|
||||
declare -A STAT_SIZES=( [mdpi]=24 [hdpi]=36 [xhdpi]=48 [xxhdpi]=72 [xxxhdpi]=96 )
|
||||
for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
|
||||
s=${STAT_SIZES[$d]}
|
||||
art=$(( s * 9 / 10 ))
|
||||
magick -background none img/goblin-logo2.svg -resize "${art}x${art}" \
|
||||
-gravity center -extent "${s}x${s}" \
|
||||
-channel RGB -evaluate set 100% +channel PNG32:"$RES/drawable-$d/ic_stat_name.png"
|
||||
done
|
||||
|
||||
# --- Windows installer + file-type icon (WiX wix/Product.ico) ---
|
||||
magick "$ICON" -define icon:auto-resize=256,128,64,48,32,24,16 wix/Product.ico
|
||||
|
||||
# --- macOS app bundle icon (Goblin.app) ---
|
||||
python3 scripts/make-icns.py "$ICON" macos/Goblin.app/Contents/Resources/AppIcon.icns
|
||||
|
||||
echo "icons generated from $ICON + $MARK"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a macOS .icns from a square PNG, dependency-free.
|
||||
|
||||
macOS has `iconutil` and Linux distros have `png2icns`, but neither is reliably
|
||||
present, and ImageMagick's own .icns writer only emits a single size. So we
|
||||
assemble the multi-resolution PNG-payload .icns container by hand (the format
|
||||
macOS 10.7+ accepts): the `icns` magic + big-endian length, then one entry per
|
||||
OSType, each carrying an 8-bit PNG. ImageMagick (`magick`) does the resizing.
|
||||
|
||||
Usage: make-icns.py <source.png> <out.icns>
|
||||
"""
|
||||
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# OSType -> pixel size. PNG-payload entries. Sizes above the source are
|
||||
# Lanczos-upscaled (soft but acceptable for the few large Dock/Finder slots).
|
||||
SLOTS = [
|
||||
(b"icp4", 16),
|
||||
(b"icp5", 32),
|
||||
(b"icp6", 64),
|
||||
(b"ic07", 128),
|
||||
(b"ic08", 256),
|
||||
(b"ic11", 32), # 16@2x
|
||||
(b"ic12", 64), # 32@2x
|
||||
(b"ic13", 256), # 128@2x
|
||||
(b"ic09", 512), # 512
|
||||
(b"ic14", 512), # 256@2x
|
||||
]
|
||||
|
||||
|
||||
def render(src, size):
|
||||
out = "/tmp/_icns_%d.png" % size
|
||||
subprocess.run(
|
||||
["magick", src, "-resize", "%dx%d" % (size, size),
|
||||
"-filter", "Lanczos", "-depth", "8", "PNG32:%s" % out],
|
||||
check=True,
|
||||
)
|
||||
return open(out, "rb").read()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit("usage: make-icns.py <source.png> <out.icns>")
|
||||
src, out = sys.argv[1], sys.argv[2]
|
||||
cache, entries = {}, []
|
||||
for ostype, size in SLOTS:
|
||||
if size not in cache:
|
||||
cache[size] = render(src, size)
|
||||
data = cache[size]
|
||||
entries.append(ostype + struct.pack(">I", 8 + len(data)) + data)
|
||||
body = b"".join(entries)
|
||||
with open(out, "wb") as f:
|
||||
f.write(b"icns" + struct.pack(">I", 8 + len(body)) + body)
|
||||
print("wrote %s (%d entries)" % (out, len(entries)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -49,11 +49,12 @@ fetch_zig() {
|
||||
}
|
||||
|
||||
fetch_appimage() {
|
||||
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && { echo "appimagetool ${AT_VER}: present"; return; }
|
||||
echo "appimage: fetching appimagetool ${AT_VER} + type2 runtime…"
|
||||
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && [ -e "${TC}/runtime-aarch64" ] && { echo "appimagetool ${AT_VER}: present"; return; }
|
||||
echo "appimage: fetching appimagetool ${AT_VER} + type2 runtimes (x86_64 + aarch64)…"
|
||||
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"
|
||||
dl "${DEV}/appimage-type2-runtime/releases/download/${RT_TAG}/runtime-aarch64" "${TC}/runtime-aarch64"
|
||||
chmod +x "${TC}/appimagetool" "${TC}/runtime-x86_64" "${TC}/runtime-aarch64"
|
||||
}
|
||||
|
||||
# Assemble a minimal Android SDK (build-tools + platform + platform-tools) from
|
||||
|
||||
@@ -77,6 +77,13 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
self.first_draw = false;
|
||||
}
|
||||
|
||||
// Heartbeat for "is the app on-screen": stamp this frame, and keep a light
|
||||
// ~2s repaint cadence so the stamp stays fresh while visible. When the app
|
||||
// is backgrounded eframe stops calling this, the stamp goes stale, and
|
||||
// background workers (the @name re-verify sweep) pause until we're back.
|
||||
crate::mark_frame();
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(2));
|
||||
|
||||
// Keep the Android status-bar icons readable against the in-app theme
|
||||
// (the app draws edge-to-edge over the status bar). Only on change.
|
||||
let white_icons = crate::gui::theme::status_bar_white_icons();
|
||||
@@ -323,14 +330,38 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
|
||||
// Paint the title.
|
||||
// Paint the title. Centering on the full rect runs the tail of the
|
||||
// string under the right-side window buttons at narrow widths (the
|
||||
// "Build NNN" digits collide with the minimize caret at 390px), so
|
||||
// when the centered galley would reach the button cluster, center it
|
||||
// in the free span between the theme toggle and the buttons instead,
|
||||
// eliding if even that span is too tight.
|
||||
let title_text = format!("Goblin ツ · Build {}", crate::BUILD);
|
||||
painter.text(
|
||||
title_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
title_text,
|
||||
egui::FontId::proportional(15.0),
|
||||
Colors::title(true),
|
||||
let title_font = egui::FontId::proportional(15.0);
|
||||
let title_ink = Colors::title(true);
|
||||
const BUTTONS_LEFT_INSET: f32 = 60.0; // theme toggle
|
||||
const BUTTONS_RIGHT_INSET: f32 = 168.0; // minimize + fullscreen + close
|
||||
let free_left = title_rect.min.x + BUTTONS_LEFT_INSET;
|
||||
let free_right = title_rect.max.x - BUTTONS_RIGHT_INSET;
|
||||
let mut galley = painter.layout_no_wrap(title_text.clone(), title_font.clone(), title_ink);
|
||||
let mut center_x = title_rect.center().x;
|
||||
if center_x + galley.size().x / 2.0 > free_right {
|
||||
center_x = (free_left + free_right) / 2.0;
|
||||
if galley.size().x > free_right - free_left {
|
||||
let mut job =
|
||||
egui::text::LayoutJob::simple_singleline(title_text, title_font, title_ink);
|
||||
job.wrap =
|
||||
egui::text::TextWrapping::truncate_at_width((free_right - free_left).max(0.0));
|
||||
galley = painter.layout_job(job);
|
||||
}
|
||||
}
|
||||
painter.galley(
|
||||
egui::pos2(
|
||||
center_x - galley.size().x / 2.0,
|
||||
title_rect.center().y - galley.size().y / 2.0,
|
||||
),
|
||||
galley,
|
||||
title_ink,
|
||||
);
|
||||
|
||||
ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| {
|
||||
|
||||
@@ -40,6 +40,12 @@ pub struct Android {
|
||||
impl Android {
|
||||
/// Create new Android platform instance from provided [`AndroidApp`].
|
||||
pub fn new(app: AndroidApp) -> Self {
|
||||
// Keep a process-wide handle so non-GUI threads (the nostr service)
|
||||
// can reach Java too (see `notify_payment_received`).
|
||||
{
|
||||
let mut w_app = ANDROID_APP.write();
|
||||
*w_app = Some(app.clone());
|
||||
}
|
||||
Self {
|
||||
android_app: app,
|
||||
ctx: Arc::new(RwLock::new(None)),
|
||||
@@ -158,6 +164,38 @@ impl PlatformCallbacks for Android {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_file(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
|
||||
// Stage the bytes in the share cache (same dir the FileProvider exposes),
|
||||
// then let Java copy them to the user-chosen Storage Access Framework
|
||||
// document. Mirrors `share_data`, but the Java side uses CREATE_DOCUMENT.
|
||||
let default_cache = OsString::from(dirs::cache_dir().unwrap());
|
||||
let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache));
|
||||
file.push("share");
|
||||
if !file.exists() {
|
||||
std::fs::create_dir(file.clone())?;
|
||||
}
|
||||
file.push(&name);
|
||||
if file.exists() {
|
||||
std::fs::remove_file(file.clone())?;
|
||||
}
|
||||
let mut f = File::create_new(file.clone())?;
|
||||
f.write_all(data.as_slice())?;
|
||||
f.sync_all()?;
|
||||
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
||||
let env = vm.attach_current_thread().unwrap();
|
||||
let path_arg = env.new_string(file.to_str().unwrap()).unwrap();
|
||||
let name_arg = env.new_string(&name).unwrap();
|
||||
let _ = self.call_java_method(
|
||||
"saveFile",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(&JObject::from(path_arg)),
|
||||
JValue::Object(&JObject::from(name_arg)),
|
||||
],
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn share_text(&self, text: String) {
|
||||
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
||||
let env = vm.attach_current_thread().unwrap();
|
||||
@@ -224,6 +262,10 @@ impl PlatformCallbacks for Android {
|
||||
fn vibrate_error(&self) {
|
||||
let _ = self.call_java_method("vibrateError", "()V", &[]);
|
||||
}
|
||||
|
||||
fn vibrate_copy(&self) {
|
||||
let _ = self.call_java_method("vibrateCopy", "()V", &[]);
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
@@ -231,6 +273,89 @@ lazy_static! {
|
||||
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
/// Picked file path.
|
||||
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
/// App handle for JNI calls from threads without a platform reference.
|
||||
static ref ANDROID_APP: Arc<RwLock<Option<AndroidApp>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
/// Show the one-shot "payment received" system notification (Java side
|
||||
/// `BackgroundService.notifyPaymentReceived`, id=2, separate from the
|
||||
/// persistent sync notification id=1). Called by the nostr service on
|
||||
/// slatepack receipt from a non-GUI thread, hence the stored [`AndroidApp`]
|
||||
/// handle instead of a platform reference. Fail-open: a missing handle or
|
||||
/// JNI error just skips the notification, never the payment.
|
||||
pub fn notify_payment_received(name: &str, amount: &str) {
|
||||
let app = {
|
||||
let r_app = ANDROID_APP.read();
|
||||
r_app.clone()
|
||||
};
|
||||
let Some(app) = app else {
|
||||
return;
|
||||
};
|
||||
let platform = Android {
|
||||
android_app: app,
|
||||
ctx: Arc::new(RwLock::new(None)),
|
||||
};
|
||||
let Ok(vm) = (unsafe { jni::JavaVM::from_raw(platform.android_app.vm_as_ptr() as _) }) else {
|
||||
return;
|
||||
};
|
||||
let Ok(env) = vm.attach_current_thread() else {
|
||||
return;
|
||||
};
|
||||
let Ok(j_name) = env.new_string(name) else {
|
||||
return;
|
||||
};
|
||||
let Ok(j_amount) = env.new_string(amount) else {
|
||||
return;
|
||||
};
|
||||
let _ = platform.call_java_method(
|
||||
"notifyPaymentReceived",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(&JObject::from(j_name)),
|
||||
JValue::Object(&JObject::from(j_amount)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Show the one-shot "payment requested" system notification (Java side
|
||||
/// `BackgroundService.notifyPaymentRequested`, id=3, separate from both the
|
||||
/// persistent sync notification id=1 and the received-payment one id=2). Called
|
||||
/// by the nostr service when a payment request (Invoice1) is ingested from a
|
||||
/// non-GUI thread, hence the stored [`AndroidApp`] handle instead of a platform
|
||||
/// reference. Fail-open: a missing handle or JNI error just skips the
|
||||
/// notification, never the request. Mirrors [`notify_payment_received`].
|
||||
pub fn notify_payment_requested(name: &str, amount: &str) {
|
||||
let app = {
|
||||
let r_app = ANDROID_APP.read();
|
||||
r_app.clone()
|
||||
};
|
||||
let Some(app) = app else {
|
||||
return;
|
||||
};
|
||||
let platform = Android {
|
||||
android_app: app,
|
||||
ctx: Arc::new(RwLock::new(None)),
|
||||
};
|
||||
let Ok(vm) = (unsafe { jni::JavaVM::from_raw(platform.android_app.vm_as_ptr() as _) }) else {
|
||||
return;
|
||||
};
|
||||
let Ok(env) = vm.attach_current_thread() else {
|
||||
return;
|
||||
};
|
||||
let Ok(j_name) = env.new_string(name) else {
|
||||
return;
|
||||
};
|
||||
let Ok(j_amount) = env.new_string(amount) else {
|
||||
return;
|
||||
};
|
||||
let _ = platform.call_java_method(
|
||||
"notifyPaymentRequested",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(&JObject::from(j_name)),
|
||||
JValue::Object(&JObject::from(j_amount)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Callback from Java code with last entered character from soft keyboard.
|
||||
|
||||
@@ -39,6 +39,17 @@ pub struct Desktop {
|
||||
|
||||
/// Flag to check if attention required after window focusing.
|
||||
attention_required: Arc<AtomicBool>,
|
||||
|
||||
/// Long-lived clipboard owner. On Linux (X11 AND Wayland) the clipboard
|
||||
/// selection is owned by the live `arboard::Clipboard` instance: the prior
|
||||
/// code created a fresh instance per call and dropped it the moment the
|
||||
/// function returned, so the selection ownership was released immediately and
|
||||
/// copied text (e.g. a recovery phrase) vanished before it could be pasted —
|
||||
/// the "Paste does nothing on desktop" bug. Keeping ONE instance alive for
|
||||
/// the app lifetime makes our process the durable selection owner, so a copy
|
||||
/// survives long enough to paste (in-app or into another window). Held behind
|
||||
/// a Mutex because the trait methods take `&self` and arboard's take `&mut`.
|
||||
clipboard: Arc<parking_lot::Mutex<Option<arboard::Clipboard>>>,
|
||||
}
|
||||
|
||||
impl Desktop {
|
||||
@@ -49,9 +60,28 @@ impl Desktop {
|
||||
camera_index: Arc::new(AtomicUsize::new(0)),
|
||||
stop_camera: Arc::new(AtomicBool::new(false)),
|
||||
attention_required: Arc::new(AtomicBool::new(false)),
|
||||
clipboard: Arc::new(parking_lot::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` against the process-wide, lazily-created clipboard instance,
|
||||
/// returning `default` if the clipboard backend can't be opened. Reusing one
|
||||
/// instance (rather than `Clipboard::new()` per call) is what keeps a copied
|
||||
/// selection alive on Linux — see the `clipboard` field.
|
||||
fn with_clipboard<R>(&self, f: impl FnOnce(&mut arboard::Clipboard) -> R, default: R) -> R {
|
||||
let mut guard = self.clipboard.lock();
|
||||
if guard.is_none() {
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(c) => *guard = Some(c),
|
||||
Err(e) => {
|
||||
log::error!("clipboard: failed to open: {e}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
f(guard.as_mut().unwrap())
|
||||
}
|
||||
|
||||
// #[allow(dead_code)]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn start_camera_capture(
|
||||
@@ -211,13 +241,24 @@ impl PlatformCallbacks for Desktop {
|
||||
}
|
||||
|
||||
fn copy_string_to_buffer(&self, data: String) {
|
||||
let mut clipboard = arboard::Clipboard::new().unwrap();
|
||||
clipboard.set_text(data).unwrap();
|
||||
// Reuse the long-lived instance so the selection survives the call (see
|
||||
// the `clipboard` field). A backend error is logged, never a panic — a
|
||||
// failed copy must not crash the wallet.
|
||||
self.with_clipboard(
|
||||
|clipboard| {
|
||||
if let Err(e) = clipboard.set_text(data) {
|
||||
log::error!("clipboard: set_text failed: {e}");
|
||||
}
|
||||
},
|
||||
(),
|
||||
);
|
||||
}
|
||||
|
||||
fn get_string_from_buffer(&self) -> String {
|
||||
let mut clipboard = arboard::Clipboard::new().unwrap();
|
||||
clipboard.get_text().unwrap_or("".to_string())
|
||||
self.with_clipboard(
|
||||
|clipboard| clipboard.get_text().unwrap_or_default(),
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn start_camera(&self) {
|
||||
@@ -359,3 +400,28 @@ lazy_static! {
|
||||
/// Last captured image from started camera.
|
||||
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clipboard_tests {
|
||||
use super::*;
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
|
||||
/// Round-trips a copy then paste through the REAL `Desktop` platform impl on
|
||||
/// the live session clipboard. Ignored by default (needs a display/clipboard
|
||||
/// backend); run manually with a Wayland/X11 session:
|
||||
/// cargo test --lib clipboard_roundtrip -- --ignored --nocapture
|
||||
/// Proves the persistent-instance fix: with the old fresh-instance-per-call
|
||||
/// pattern this read back empty on Wayland/X11.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn clipboard_roundtrip() {
|
||||
let d = Desktop::new();
|
||||
let phrase = "abandon ability able about above absent absorb abstract \
|
||||
absurd abuse access accident account accuse achieve acid acoustic \
|
||||
acquire across act action actor actress";
|
||||
d.copy_string_to_buffer(phrase.to_string());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
let got = d.get_string_from_buffer();
|
||||
assert_eq!(got, phrase, "clipboard round-trip lost the copied text");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ 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>;
|
||||
|
||||
/// Save bytes to a user-chosen location on the device (a "save as" dialog).
|
||||
/// Desktop already does this via `share_data` (rfd save dialog); Android
|
||||
/// overrides to use the Storage Access Framework (ACTION_CREATE_DOCUMENT)
|
||||
/// instead of the share sheet.
|
||||
fn save_file(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
|
||||
self.share_data(name, data)
|
||||
}
|
||||
/// Share plain text via the platform's native share sheet (e.g. a payment
|
||||
/// link). Defaults to copying to the clipboard on platforms without a share
|
||||
/// sheet (desktop).
|
||||
@@ -57,4 +65,7 @@ pub trait PlatformCallbacks {
|
||||
/// Play a short "error" haptic (e.g. a rejected over-balance payment).
|
||||
/// No-op off Android.
|
||||
fn vibrate_error(&self) {}
|
||||
|
||||
/// Play a tiny "tick" haptic confirming a successful copy. No-op off Android.
|
||||
fn vibrate_copy(&self) {}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ thread_local! {
|
||||
/// 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.
|
||||
/// chosen theme, à la a modern pay app's brand-colored pay screen.
|
||||
#[must_use = "the override only lasts while the guard is alive"]
|
||||
pub struct ScopedTheme(Option<ThemeKind>);
|
||||
|
||||
@@ -224,9 +224,22 @@ pub fn tokens() -> &'static ThemeTokens {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set each frame by the Pay surface (which paints a bright yellow top under a
|
||||
/// possibly-dark global theme), so the status bar can pick readable icons for it.
|
||||
static YELLOW_SURFACE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Flag whether the bright Pay/yellow surface is currently on screen.
|
||||
pub fn set_status_surface_yellow(yellow: bool) {
|
||||
YELLOW_SURFACE.store(yellow, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the status bar should use light (white) icons: true on the dark
|
||||
/// theme (dark top), false on the light/yellow themes (bright top).
|
||||
/// theme (dark top), false on the light/yellow themes (bright top). The bright
|
||||
/// Pay surface forces dark icons even when the global theme is dark.
|
||||
pub fn status_bar_white_icons() -> bool {
|
||||
if YELLOW_SURFACE.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
tokens().dark_base
|
||||
}
|
||||
|
||||
@@ -338,12 +351,6 @@ pub fn ink_for(bg: Color32) -> Color32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -144,6 +144,9 @@ impl ContentContainer for Content {
|
||||
.show();
|
||||
} else if OperatingSystem::from_target_os() == OperatingSystem::Android
|
||||
&& AppConfig::android_integrated_node_warning_needed()
|
||||
// The warning is about INTEGRATED-node background sync; on the
|
||||
// external-node default it nags about a node we do not run.
|
||||
&& AppConfig::autostart_node()
|
||||
{
|
||||
Modal::new(ANDROID_INTEGRATED_NODE_WARNING_MODAL)
|
||||
.title(t!("network.node"))
|
||||
|
||||
@@ -30,7 +30,7 @@ enum Fetched {
|
||||
Found(String, Vec<u8>),
|
||||
/// The server confirmed the name has no avatar.
|
||||
Absent,
|
||||
/// The probe failed (network/Tor) — do NOT cache; retry later.
|
||||
/// The probe failed (network) — do NOT cache; retry later.
|
||||
Failed,
|
||||
}
|
||||
type FetchResult = (String, Fetched);
|
||||
@@ -115,15 +115,6 @@ impl AvatarTextures {
|
||||
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();
|
||||
@@ -146,9 +137,8 @@ impl AvatarTextures {
|
||||
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".
|
||||
// Network failure: leave the entry stale so the next frame
|
||||
// retries. Never cache it as a confirmed "no avatar".
|
||||
Fetched::Failed => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
use grin_wallet_libwallet::TxLogEntryType;
|
||||
|
||||
use crate::nostr::{Contact, NostrSendStatus, NostrStore, TxNostrMeta};
|
||||
use crate::nostr::{Contact, NewsItem, NostrSendStatus, NostrStore, TxNostrMeta};
|
||||
use crate::wallet::Wallet;
|
||||
use crate::wallet::types::WalletTx;
|
||||
|
||||
@@ -31,7 +31,6 @@ pub struct ActivityItem {
|
||||
/// 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>,
|
||||
@@ -44,7 +43,6 @@ pub struct ActivityItem {
|
||||
pub struct ReceiptDetail {
|
||||
pub tx_id: u32,
|
||||
pub title: String,
|
||||
pub hue: usize,
|
||||
pub npub: Option<String>,
|
||||
pub amount: u64,
|
||||
pub incoming: bool,
|
||||
@@ -79,18 +77,16 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
||||
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)
|
||||
let title = if system {
|
||||
"Mining reward".to_string()
|
||||
} else if let Some(m) = &meta {
|
||||
store_ref
|
||||
.map(|s| contact_title(s, &m.npub))
|
||||
.unwrap_or_else(|| (short_npub(&m.npub), 0))
|
||||
.unwrap_or_else(|| short_npub(&m.npub))
|
||||
} else if incoming {
|
||||
"Received".to_string()
|
||||
} else {
|
||||
let label = if incoming { "Received" } else { "Sent" };
|
||||
(
|
||||
label.to_string(),
|
||||
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
|
||||
)
|
||||
"Sent".to_string()
|
||||
};
|
||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
||||
let time = tx
|
||||
@@ -105,16 +101,25 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
||||
} 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)),
|
||||
// Confirmation progress toward the spendable threshold (min_confirmations).
|
||||
// grin flips `confirmed` to true at the FIRST on-chain block, but a payment
|
||||
// isn't spendable until min_confirmations — so keep counting 1/10 … 10/10
|
||||
// instead of jumping straight to "complete" at one block (which is why the
|
||||
// count never appeared to move).
|
||||
let min_conf = data.info.minimum_confirmations;
|
||||
let confs = match tx.height {
|
||||
Some(h) if h > 0 && data.info.last_confirmed_height >= h => {
|
||||
let count = data.info.last_confirmed_height - h + 1;
|
||||
if count >= min_conf {
|
||||
None // matured — fully spendable
|
||||
} else {
|
||||
Some((count, min_conf))
|
||||
}
|
||||
}
|
||||
// On-chain but exact height not yet known: at least one block in.
|
||||
_ if tx.data.confirmed => Some((1.min(min_conf), min_conf)),
|
||||
// Broadcast but not yet mined.
|
||||
_ => Some((0, min_conf)),
|
||||
};
|
||||
let canceled = is_canceled(tx, meta.as_ref());
|
||||
let has_identity = meta
|
||||
@@ -124,7 +129,6 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
||||
Some(ReceiptDetail {
|
||||
tx_id,
|
||||
title,
|
||||
hue,
|
||||
npub: meta.map(|m| m.npub),
|
||||
amount: tx.amount,
|
||||
incoming,
|
||||
@@ -175,16 +179,17 @@ fn is_canceled(tx: &WalletTx, meta: Option<&TxNostrMeta>) -> bool {
|
||||
}
|
||||
|
||||
/// Resolve the display title for a contact npub.
|
||||
pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) {
|
||||
pub fn contact_title(store: &NostrStore, npub: &str) -> String {
|
||||
if let Some(contact) = store.contact(npub) {
|
||||
(display_name(&contact), contact.hue as usize)
|
||||
display_name(&contact)
|
||||
} else {
|
||||
let hue = hue_of(&npub);
|
||||
(short_npub(npub), hue)
|
||||
short_npub(npub)
|
||||
}
|
||||
}
|
||||
|
||||
/// Display rule: petname → @user (verified goblin.st) → user@domain → npub short.
|
||||
/// Display rule: petname → bare name (verified, home authority) → `name · domain`
|
||||
/// (verified, foreign authority — never bare, so a foreign "alice" can't pose as
|
||||
/// your home "alice") → short npub. We never show the `@`.
|
||||
pub fn display_name(contact: &Contact) -> String {
|
||||
if let Some(petname) = &contact.petname {
|
||||
if !petname.is_empty() {
|
||||
@@ -193,18 +198,34 @@ pub fn display_name(contact: &Contact) -> String {
|
||||
}
|
||||
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);
|
||||
if domain == crate::nostr::nip05::home_domain() {
|
||||
return name.to_string();
|
||||
}
|
||||
return nip05.clone();
|
||||
// Foreign authority: show the domain (no @) so it can't masquerade
|
||||
// as a home name.
|
||||
return format!("{name} · {domain}");
|
||||
}
|
||||
}
|
||||
short_npub(&contact.npub)
|
||||
}
|
||||
|
||||
/// Short npub display (npub1abcd…wxyz) from a hex pubkey.
|
||||
/// Whether this contact's name is verified against a name authority (gets the
|
||||
/// little check), and the foreign domain to surface (None when it's the home
|
||||
/// authority, where the domain is implied).
|
||||
pub fn name_verification(contact: &Contact) -> Option<Option<String>> {
|
||||
let nip05 = contact.nip05.as_ref()?;
|
||||
contact.nip05_verified_at?;
|
||||
let (_, domain) = nip05.split_once('@')?;
|
||||
if domain == crate::nostr::nip05::home_domain() {
|
||||
Some(None)
|
||||
} else {
|
||||
Some(Some(domain.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar hue index derived from a hex pubkey (stable per identity, spread
|
||||
/// across the full color-pair palette).
|
||||
/// across the full color-pair palette). Only fills the persisted
|
||||
/// `Contact.hue` field these days — nothing reads it for rendering anymore.
|
||||
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()
|
||||
@@ -222,16 +243,17 @@ pub fn short_handle(handle: &str) -> String {
|
||||
format!("{head}…{tail}")
|
||||
}
|
||||
|
||||
/// Short npub display (npub1abcd…wxyz) from a hex pubkey.
|
||||
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;
|
||||
// `to_bech32` for a valid key is infallible.
|
||||
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())])
|
||||
}
|
||||
@@ -273,23 +295,17 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
.as_ref()
|
||||
.and_then(|sid| store.and_then(|s| s.tx_meta(sid)));
|
||||
|
||||
let (title, hue) = if system {
|
||||
("Mining reward".to_string(), 5)
|
||||
let title = if system {
|
||||
"Mining reward".to_string()
|
||||
} else if let Some(meta) = &meta {
|
||||
store
|
||||
.map(|s| contact_title(s, &meta.npub))
|
||||
.unwrap_or_else(|| (short_npub(&meta.npub), 0))
|
||||
.unwrap_or_else(|| short_npub(&meta.npub))
|
||||
} else if incoming {
|
||||
// Fall back to a generic label when there's no nostr counterparty.
|
||||
"Received".to_string()
|
||||
} 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(),
|
||||
)
|
||||
"Sent".to_string()
|
||||
};
|
||||
|
||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
||||
@@ -310,14 +326,14 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
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)> {
|
||||
/// Recent unique peers for the home strip (most recent first), as
|
||||
/// `(display name, npub hex)`.
|
||||
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, String)> {
|
||||
let store = match wallet.nostr_service() {
|
||||
Some(s) => s.store.clone(),
|
||||
None => return vec![],
|
||||
@@ -327,13 +343,14 @@ pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String
|
||||
contacts
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|c| (display_name(&c), c.hue as usize, c.npub))
|
||||
.map(|c| (display_name(&c), 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)> {
|
||||
/// Returns `(display name, npub hex)` pairs.
|
||||
pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, String)> {
|
||||
let store = match wallet.nostr_service() {
|
||||
Some(s) => s.store.clone(),
|
||||
None => return vec![],
|
||||
@@ -342,7 +359,7 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
|
||||
if q.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut hits: Vec<(String, usize, String)> = store
|
||||
let mut hits: Vec<(String, String)> = store
|
||||
.all_contacts()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
@@ -356,8 +373,368 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
|
||||
.unwrap_or(false)
|
||||
|| c.npub.to_lowercase().contains(&q)
|
||||
})
|
||||
.map(|c| (display_name(&c), c.hue as usize, c.npub))
|
||||
.map(|c| (display_name(&c), c.npub))
|
||||
.collect();
|
||||
hits.truncate(limit);
|
||||
hits
|
||||
}
|
||||
|
||||
/// The news post to show in the Home panel for the wallet's active language, or
|
||||
/// `None` (panel hides). Selection is language-aware: the newest article whose
|
||||
/// detected language matches the app locale, falling back to the newest English
|
||||
/// article. The returned item's title has any `[xx]` language marker stripped
|
||||
/// for display. `GOBLIN_FAKE_NEWS=1` injects a fixed multilingual set in debug
|
||||
/// builds so the panel can be screenshotted without a live relay feed.
|
||||
pub fn news_latest(wallet: &Wallet) -> Option<NewsItem> {
|
||||
let items = news_pool(wallet);
|
||||
let mut item = select_news(&items, &news_locale_code())?;
|
||||
item.title = news_display_title(&item.title);
|
||||
Some(item)
|
||||
}
|
||||
|
||||
/// The candidate news set (all cached posts), or a fixed multilingual sample
|
||||
/// under `GOBLIN_FAKE_NEWS` in debug builds. Kept separate from selection so the
|
||||
/// selection logic stays a pure, unit-testable function.
|
||||
fn news_pool(wallet: &Wallet) -> Vec<NewsItem> {
|
||||
#[cfg(debug_assertions)]
|
||||
if std::env::var("GOBLIN_FAKE_NEWS").is_ok() {
|
||||
return vec![
|
||||
NewsItem {
|
||||
d: "welcome-en".to_string(),
|
||||
created_at: 100,
|
||||
title: "Welcome to Goblin".to_string(),
|
||||
summary: "Private grin payments over Tor. Read more: https://docs.goblin.st"
|
||||
.to_string(),
|
||||
lang: None,
|
||||
published_at: Some(1_782_864_000), // 2026-07-01 UTC
|
||||
},
|
||||
NewsItem {
|
||||
d: "welcome-de".to_string(),
|
||||
created_at: 100,
|
||||
title: "Willkommen bei Goblin [de]".to_string(),
|
||||
summary: "Private Grin-Zahlungen über Tor. Mehr dazu: https://docs.goblin.st"
|
||||
.to_string(),
|
||||
lang: None,
|
||||
published_at: Some(1_782_864_000), // 2026-07-01 UTC
|
||||
},
|
||||
];
|
||||
}
|
||||
wallet
|
||||
.nostr_service()
|
||||
.map(|s| s.store.all_news())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The app's active locale folded to the ISO 639-1 code used to match news
|
||||
/// articles. The shipped locales are `en/de/fr/ru/tr/zh-CN`; only `zh-CN` needs
|
||||
/// folding to its 639-1 primary `zh`, and every other locale already is a
|
||||
/// two-letter primary. Region and separator (`-`/`_`) are dropped.
|
||||
fn news_locale_code() -> String {
|
||||
let loc = rust_i18n::locale().to_string().to_lowercase();
|
||||
loc.split(['-', '_']).next().unwrap_or("en").to_string()
|
||||
}
|
||||
|
||||
/// Detect an article's language as a lower-case ISO 639-1 code. Priority: the
|
||||
/// stored event language tag, then a trailing `[xx]` marker on the title, else
|
||||
/// English (`None`). Pure — the unit tests exercise it directly.
|
||||
pub fn news_language(item: &NewsItem) -> Option<String> {
|
||||
if let Some(l) = &item.lang {
|
||||
let l = l.trim().to_lowercase();
|
||||
if is_lang_code(&l) {
|
||||
return Some(l);
|
||||
}
|
||||
}
|
||||
title_lang_marker(&item.title)
|
||||
}
|
||||
|
||||
/// The trailing `[xx]` marker on a title (case-insensitive, `xx` = two ASCII
|
||||
/// letters), as a lower-case code, or `None`. Only a marker at the very end of
|
||||
/// the (trimmed) title counts, so a `[link]` mid-sentence is never mistaken for
|
||||
/// a language.
|
||||
fn title_lang_marker(title: &str) -> Option<String> {
|
||||
let t = title.trim();
|
||||
let inner = t.strip_suffix(']')?.rsplit_once('[')?.1;
|
||||
let code = inner.trim().to_lowercase();
|
||||
if is_lang_code(&code) {
|
||||
Some(code)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A displayable title with any trailing `[xx]` language marker removed.
|
||||
pub fn news_display_title(title: &str) -> String {
|
||||
match title_lang_marker(title) {
|
||||
Some(_) => {
|
||||
let t = title.trim_end();
|
||||
// Drop the `[xx]` token and the whitespace that preceded it.
|
||||
match t.rfind('[') {
|
||||
Some(idx) => t[..idx].trim_end().to_string(),
|
||||
None => t.to_string(),
|
||||
}
|
||||
}
|
||||
None => title.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a unix timestamp (seconds) as an ISO-8601 calendar date in UTC
|
||||
/// (`YYYY-MM-DD`), never a US `M/D/Y`, and date only (no time-of-day, unlike the
|
||||
/// activity feed). Dates the Home news panel. An out-of-range stamp falls back to
|
||||
/// the epoch date rather than panicking.
|
||||
pub fn news_date_iso(ts: i64) -> String {
|
||||
use chrono::{TimeZone, Utc};
|
||||
Utc.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_else(|| "1970-01-01".to_string())
|
||||
}
|
||||
|
||||
/// The hard character budget for a Home news title before it ellipsizes. Titles
|
||||
/// up to ~34 chars sit at the full 16pt on a 390px phone; between there and this
|
||||
/// cap the panel shrinks the font to keep one line; past it they are ellipsized.
|
||||
/// This is the author's predictable writing budget.
|
||||
pub const NEWS_TITLE_MAX_CHARS: usize = 48;
|
||||
|
||||
/// A news title clamped to [`NEWS_TITLE_MAX_CHARS`], ellipsizing (`…`) past it so
|
||||
/// an over-long title is handled predictably rather than relying on layout alone.
|
||||
/// The shrink-to-fit font sizing in the panel is the second, screen-width-aware
|
||||
/// half of the guardrail. Clamps on `char` boundaries so multi-byte titles are
|
||||
/// never split mid-codepoint.
|
||||
pub fn news_title_clamped(title: &str) -> String {
|
||||
let chars: Vec<char> = title.chars().collect();
|
||||
if chars.len() > NEWS_TITLE_MAX_CHARS {
|
||||
let keep = NEWS_TITLE_MAX_CHARS.saturating_sub(1);
|
||||
format!("{}…", chars[..keep].iter().collect::<String>())
|
||||
} else {
|
||||
title.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a two-letter ASCII-alphabetic language code.
|
||||
fn is_lang_code(s: &str) -> bool {
|
||||
s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Select the news article to show for `target` (an ISO 639-1 code): the newest
|
||||
/// article in that language, else the newest English article (English = an
|
||||
/// explicit `en` OR no detected language). Pure so it is unit-testable without a
|
||||
/// wallet/store. Ties on `created_at` resolve to the last such article.
|
||||
pub fn select_news(items: &[NewsItem], target: &str) -> Option<NewsItem> {
|
||||
let target = if target.is_empty() { "en" } else { target };
|
||||
let lang_of = |it: &NewsItem| news_language(it).unwrap_or_else(|| "en".to_string());
|
||||
items
|
||||
.iter()
|
||||
.filter(|it| lang_of(it) == target)
|
||||
.max_by_key(|it| it.created_at)
|
||||
.or_else(|| {
|
||||
items
|
||||
.iter()
|
||||
.filter(|it| lang_of(it) == "en")
|
||||
.max_by_key(|it| it.created_at)
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Split a plain-text summary into (segment, is_url) runs so http(s) URLs render
|
||||
/// as tappable links and the rest as plain labels. Trailing sentence
|
||||
/// punctuation is trimmed off a URL so "…goblin.st." doesn't link the dot.
|
||||
pub fn split_urls(s: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s;
|
||||
while let Some(idx) = rest.find("http") {
|
||||
let candidate = &rest[idx..];
|
||||
if candidate.starts_with("http://") || candidate.starts_with("https://") {
|
||||
if idx > 0 {
|
||||
out.push((rest[..idx].to_string(), false));
|
||||
}
|
||||
let end = candidate
|
||||
.find(char::is_whitespace)
|
||||
.unwrap_or(candidate.len());
|
||||
let mut url = &candidate[..end];
|
||||
while let Some(last) = url.chars().last() {
|
||||
if matches!(last, '.' | ',' | ')' | ']' | '}' | '!' | '?' | ';' | ':') {
|
||||
url = &url[..url.len() - last.len_utf8()];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push((url.to_string(), true));
|
||||
rest = &candidate[url.len()..];
|
||||
} else {
|
||||
// A bare "http" that isn't a scheme; emit it as text and move past it.
|
||||
let split_at = idx + 4;
|
||||
out.push((rest[..split_at].to_string(), false));
|
||||
rest = &rest[split_at..];
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
out.push((rest.to_string(), false));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_urls_isolates_links() {
|
||||
let segs = split_urls("Tor is live. Read more: https://docs.goblin.st now");
|
||||
assert_eq!(
|
||||
segs,
|
||||
vec![
|
||||
("Tor is live. Read more: ".to_string(), false),
|
||||
("https://docs.goblin.st".to_string(), true),
|
||||
(" now".to_string(), false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_urls_trims_trailing_punctuation_and_handles_no_url() {
|
||||
let segs = split_urls("See https://x.io.");
|
||||
assert_eq!(
|
||||
segs,
|
||||
vec![
|
||||
("See ".to_string(), false),
|
||||
("https://x.io".to_string(), true),
|
||||
(".".to_string(), false),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
split_urls("plain text"),
|
||||
vec![("plain text".to_string(), false)]
|
||||
);
|
||||
}
|
||||
|
||||
fn news(d: &str, created_at: i64, title: &str, lang: Option<&str>) -> NewsItem {
|
||||
NewsItem {
|
||||
d: d.to_string(),
|
||||
created_at,
|
||||
title: title.to_string(),
|
||||
summary: String::new(),
|
||||
lang: lang.map(|s| s.to_string()),
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_from_event_tag_wins() {
|
||||
// A stored event language tag is authoritative, even if the title has no
|
||||
// marker (bare `["l","de"]`) or a differing marker.
|
||||
let it = news("a", 1, "Neuigkeiten", Some("de"));
|
||||
assert_eq!(news_language(&it).as_deref(), Some("de"));
|
||||
// NIP-32-style tag is stored the same way (code already extracted upstream).
|
||||
let it = news("b", 1, "News", Some("FR"));
|
||||
assert_eq!(news_language(&it).as_deref(), Some("fr"));
|
||||
// A non-code tag value is ignored, falling through to the title (English).
|
||||
let it = news("c", 1, "News", Some("english"));
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_from_title_suffix_marker() {
|
||||
let it = news("a", 1, "2026-07-05 Welcome to Goblin [de]", None);
|
||||
assert_eq!(news_language(&it).as_deref(), Some("de"));
|
||||
// Case-insensitive.
|
||||
let it = news("b", 1, "Bonjour [FR]", None);
|
||||
assert_eq!(news_language(&it).as_deref(), Some("fr"));
|
||||
// Only a marker at the very end counts; a bracketed word mid-title does not.
|
||||
let it = news("c", 1, "Read the [guide] today", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_means_english() {
|
||||
let it = news("a", 1, "Welcome to Goblin", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
// A non-two-letter bracket suffix is not a language marker.
|
||||
let it = news("b", 1, "Build 137 [beta]", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_title_strips_marker() {
|
||||
assert_eq!(
|
||||
news_display_title("2026-07-05 Welcome to Goblin [de]"),
|
||||
"2026-07-05 Welcome to Goblin"
|
||||
);
|
||||
assert_eq!(news_display_title("Bonjour [FR]"), "Bonjour");
|
||||
// No marker: unchanged.
|
||||
assert_eq!(news_display_title("Welcome to Goblin"), "Welcome to Goblin");
|
||||
// Non-language bracket suffix: left intact.
|
||||
assert_eq!(news_display_title("Build 137 [beta]"), "Build 137 [beta]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_is_iso_utc_day_only() {
|
||||
// 2026-07-01 00:00:00 UTC.
|
||||
assert_eq!(news_date_iso(1_782_864_000), "2026-07-01");
|
||||
// A within-the-day stamp still yields the same calendar date (no time).
|
||||
assert_eq!(news_date_iso(1_782_864_000 + 3600 * 13 + 59), "2026-07-01");
|
||||
// Epoch.
|
||||
assert_eq!(news_date_iso(0), "1970-01-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_clamped_ellipsizes_past_max() {
|
||||
// Short titles pass through untouched.
|
||||
let short = "News in Your Language";
|
||||
assert_eq!(news_title_clamped(short), short);
|
||||
// Exactly the cap is untouched.
|
||||
let at_cap = "x".repeat(NEWS_TITLE_MAX_CHARS);
|
||||
assert_eq!(news_title_clamped(&at_cap), at_cap);
|
||||
// One over the cap ellipsizes to exactly the cap length (… included).
|
||||
let over = "y".repeat(NEWS_TITLE_MAX_CHARS + 10);
|
||||
let clamped = news_title_clamped(&over);
|
||||
assert_eq!(clamped.chars().count(), NEWS_TITLE_MAX_CHARS);
|
||||
assert!(clamped.ends_with('…'));
|
||||
// Multi-byte titles clamp on char boundaries (no panic / no split codepoint).
|
||||
let cjk = "语".repeat(NEWS_TITLE_MAX_CHARS + 5);
|
||||
let clamped = news_title_clamped(&cjk);
|
||||
assert_eq!(clamped.chars().count(), NEWS_TITLE_MAX_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_matches_locale_then_falls_back_to_english() {
|
||||
let pool = vec![
|
||||
news("en", 100, "Welcome to Goblin", None),
|
||||
news("de", 90, "Willkommen bei Goblin [de]", None),
|
||||
news("fr", 80, "Bonjour", Some("fr")),
|
||||
];
|
||||
// German locale → the German article.
|
||||
assert_eq!(select_news(&pool, "de").unwrap().d, "de");
|
||||
// French locale (via event tag) → the French article.
|
||||
assert_eq!(select_news(&pool, "fr").unwrap().d, "fr");
|
||||
// English locale → the English (unmarked) article.
|
||||
assert_eq!(select_news(&pool, "en").unwrap().d, "en");
|
||||
// A locale with no article → fall back to the newest English article.
|
||||
assert_eq!(select_news(&pool, "ru").unwrap().d, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_picks_newest_within_language_slice() {
|
||||
let pool = vec![
|
||||
news("de-old", 50, "Alt [de]", None),
|
||||
news("de-new", 150, "Neu [de]", None),
|
||||
news("en", 200, "Newest overall", None),
|
||||
];
|
||||
// Within German, the newest German article wins — NOT the newer English one.
|
||||
assert_eq!(select_news(&pool, "de").unwrap().d, "de-new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_folding_maps_zh_cn_to_zh() {
|
||||
rust_i18n::set_locale("zh-CN");
|
||||
assert_eq!(news_locale_code(), "zh");
|
||||
rust_i18n::set_locale("de");
|
||||
assert_eq!(news_locale_code(), "de");
|
||||
rust_i18n::set_locale("en");
|
||||
assert_eq!(news_locale_code(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pool_selects_nothing() {
|
||||
assert!(select_news(&[], "de").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ 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 {
|
||||
/// Standard HSL → RGB bytes. f64 throughout for cross-port byte-identity.
|
||||
pub(super) fn hsl_rgb8(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
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());
|
||||
@@ -51,7 +51,13 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
||||
};
|
||||
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))
|
||||
(to(r), to(g), to(b))
|
||||
}
|
||||
|
||||
/// Standard HSL → RGB → `#rrggbb`.
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
||||
let (r, g, b) = hsl_rgb8(h, s, l);
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
/// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical
|
||||
@@ -79,17 +85,6 @@ fn gradient_params(hex: &str) -> (String, String, f64) {
|
||||
(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
|
||||
|
||||
@@ -21,13 +21,14 @@ 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::icons::{ARROW_LEFT, CHECK};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::theme::{self, fonts};
|
||||
use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult};
|
||||
use crate::gui::views::wallets::creation::MnemonicSetup;
|
||||
use crate::gui::views::{CameraScanContent, Content, Modal, TextEdit, View};
|
||||
use crate::node::Node;
|
||||
use crate::nostr::NostrIdentity;
|
||||
use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize};
|
||||
use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList};
|
||||
|
||||
@@ -39,6 +40,7 @@ const OB_PHRASE_SCAN_MODAL: &'static str = "ob_phrase_scan_modal";
|
||||
|
||||
/// Onboarding step.
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
#[allow(dead_code)] // Node step retired from the flow; node mgmt lives in Settings/Advanced
|
||||
enum Step {
|
||||
Intro,
|
||||
Node,
|
||||
@@ -69,13 +71,41 @@ pub struct OnboardingContent {
|
||||
wallet: Option<Wallet>,
|
||||
/// Optional username claim state (same machinery as Settings).
|
||||
claim: ClaimState,
|
||||
/// Optional "import an existing identity" sub-flow, opened from the identity
|
||||
/// step so a returning user can keep their old npub + username instead of the
|
||||
/// freshly-generated random key.
|
||||
import: Option<OnbImport>,
|
||||
/// Moment the recovery phrase was copied, for the transient "Copied" check.
|
||||
words_copied: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// Onboarding identity-import state. Reuses the wallet password the user just
|
||||
/// set, so it only needs the backup file / nsec (and the backup's own password
|
||||
/// when restoring a sealed `.backup`).
|
||||
#[derive(Default)]
|
||||
struct OnbImport {
|
||||
/// 0 = form, 1 = working, 2 = error.
|
||||
stage: u8,
|
||||
/// Pasted nsec or the read-in contents of a `.backup` / identity JSON file.
|
||||
nsec: String,
|
||||
/// Password the backup was sealed under (blank for a bare nsec, or when it
|
||||
/// matches this wallet's password).
|
||||
backup_password: String,
|
||||
/// Last import error, shown on stage 2.
|
||||
error: String,
|
||||
/// A native file pick is in flight (Android resolves the path asynchronously).
|
||||
picking: bool,
|
||||
/// Worker result: Ok(new npub) or Err(message).
|
||||
result: std::sync::Arc<std::sync::Mutex<Option<Result<String, String>>>>,
|
||||
}
|
||||
|
||||
impl Default for OnboardingContent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
step: Step::Intro,
|
||||
integrated: true,
|
||||
// Default to the Instant path (connect to a public node) so a new
|
||||
// user is online immediately, with no chain-sync wait.
|
||||
integrated: false,
|
||||
ext_url: "https://grincoin.org".to_string(),
|
||||
restore: false,
|
||||
name: "Main wallet".to_string(),
|
||||
@@ -86,6 +116,8 @@ impl Default for OnboardingContent {
|
||||
scan_modal: None,
|
||||
wallet: None,
|
||||
claim: ClaimState::default(),
|
||||
import: None,
|
||||
words_copied: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,22 +217,18 @@ impl OnboardingContent {
|
||||
);
|
||||
});
|
||||
ui.add_space(26.0);
|
||||
let lines: [(&str, &str); 3] = [
|
||||
let lines: [(String, String); 3] = [
|
||||
(
|
||||
"Private money",
|
||||
"Goblin is a wallet for grin — digital cash with no amounts \
|
||||
or addresses on its chain.",
|
||||
t!("goblin.onboarding.intro.private_money_head").to_string(),
|
||||
t!("goblin.onboarding.intro.private_money_body").to_string(),
|
||||
),
|
||||
(
|
||||
"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.",
|
||||
t!("goblin.onboarding.intro.send_like_message_head").to_string(),
|
||||
t!("goblin.onboarding.intro.send_like_message_body").to_string(),
|
||||
),
|
||||
(
|
||||
"Yours alone",
|
||||
"Keys, names and history live on this device. Built on the \
|
||||
GRIM wallet.",
|
||||
t!("goblin.onboarding.intro.yours_alone_head").to_string(),
|
||||
t!("goblin.onboarding.intro.yours_alone_body").to_string(),
|
||||
),
|
||||
];
|
||||
for (head, body) in lines {
|
||||
@@ -221,13 +249,13 @@ impl OnboardingContent {
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
if w::big_action(ui, "Get started", false).clicked() {
|
||||
self.step = Step::Node;
|
||||
if w::big_action(ui, &t!("goblin.onboarding.intro.get_started"), false).clicked() {
|
||||
self.step = Step::WalletSetup;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Takes about a minute. You can change everything later.")
|
||||
RichText::new(t!("goblin.onboarding.intro.footnote"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -293,27 +321,18 @@ impl OnboardingContent {
|
||||
let t = theme::tokens();
|
||||
self.step_header(
|
||||
ui,
|
||||
"STEP 1 OF 3 · NETWORK",
|
||||
"How should Goblin\nwatch the chain?",
|
||||
&t!("goblin.onboarding.node.kicker"),
|
||||
&t!("goblin.onboarding.node.title"),
|
||||
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);
|
||||
// Instant (connect to a public node) leads — most people want to be
|
||||
// online immediately, with no chain-sync wait.
|
||||
if Self::node_card(
|
||||
ui,
|
||||
!self.integrated,
|
||||
"Connect to a node",
|
||||
"Instant",
|
||||
"No sync wait. The node you pick can see your wallet's queries.",
|
||||
&t!("goblin.onboarding.node.connect_title"),
|
||||
&t!("goblin.onboarding.node.connect_badge"),
|
||||
&t!("goblin.onboarding.node.connect_body"),
|
||||
) {
|
||||
self.integrated = false;
|
||||
}
|
||||
@@ -328,9 +347,19 @@ impl OnboardingContent {
|
||||
.ui(ui, &mut self.ext_url, cb);
|
||||
});
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if Self::node_card(
|
||||
ui,
|
||||
self.integrated,
|
||||
&t!("goblin.onboarding.node.own_title"),
|
||||
&t!("goblin.onboarding.node.own_badge"),
|
||||
&t!("goblin.onboarding.node.own_body"),
|
||||
) {
|
||||
self.integrated = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Changeable any time in Settings → Node.")
|
||||
RichText::new(t!("goblin.onboarding.node.changeable"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -338,13 +367,13 @@ impl OnboardingContent {
|
||||
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 {
|
||||
if w::big_action(ui, &t!("goblin.onboarding.node.continue"), false).clicked() && url_ok {
|
||||
self.step = Step::WalletSetup;
|
||||
}
|
||||
if !url_ok {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Node URL must start with http:// or https://")
|
||||
RichText::new(t!("goblin.onboarding.node.url_invalid"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
@@ -355,12 +384,20 @@ impl OnboardingContent {
|
||||
|
||||
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);
|
||||
self.step_header(
|
||||
ui,
|
||||
&t!("goblin.onboarding.wallet.kicker"),
|
||||
&t!("goblin.onboarding.wallet.title"),
|
||||
Step::Intro,
|
||||
);
|
||||
|
||||
// Create / Restore segmented choice.
|
||||
ui.horizontal(|ui| {
|
||||
let half = (ui.available_width() - 10.0) / 2.0;
|
||||
for (restore, label) in [(false, "Create new"), (true, "Restore from seed")] {
|
||||
for (restore, label) in [
|
||||
(false, t!("goblin.onboarding.wallet.create_new")),
|
||||
(true, t!("goblin.onboarding.wallet.restore_from_seed")),
|
||||
] {
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
@@ -368,7 +405,7 @@ impl OnboardingContent {
|
||||
)),
|
||||
|ui| {
|
||||
let active = self.restore == restore;
|
||||
let resp = w::chip(ui, label, active);
|
||||
let resp = w::chip(ui, &label, active);
|
||||
if resp.clicked() {
|
||||
self.restore = restore;
|
||||
}
|
||||
@@ -382,7 +419,7 @@ impl OnboardingContent {
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_name"))
|
||||
.focus(false)
|
||||
.hint_text("Wallet name")
|
||||
.hint_text(t!("goblin.onboarding.wallet.name_hint"))
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.name, cb);
|
||||
@@ -391,7 +428,7 @@ impl OnboardingContent {
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_pass"))
|
||||
.focus(false)
|
||||
.hint_text("Password")
|
||||
.hint_text(t!("goblin.onboarding.wallet.password_hint"))
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
@@ -401,7 +438,7 @@ impl OnboardingContent {
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_pass2"))
|
||||
.focus(false)
|
||||
.hint_text("Repeat password")
|
||||
.hint_text(t!("goblin.onboarding.wallet.repeat_password_hint"))
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
@@ -410,10 +447,9 @@ impl OnboardingContent {
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
RichText::new(if self.restore {
|
||||
"Have your seed words ready — you'll enter them next."
|
||||
t!("goblin.onboarding.wallet.restore_hint")
|
||||
} else {
|
||||
"Next you'll get 24 seed words to write down. They are the \
|
||||
money — anyone holding them holds your funds."
|
||||
t!("goblin.onboarding.wallet.create_hint")
|
||||
})
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
@@ -422,7 +458,10 @@ impl OnboardingContent {
|
||||
|
||||
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 {
|
||||
if w::big_action(ui, &t!("goblin.onboarding.wallet.continue"), false).clicked()
|
||||
&& pass_ok
|
||||
&& name_ok
|
||||
{
|
||||
self.mnemonic_setup.reset();
|
||||
self.mnemonic_setup.mnemonic.set_mode(if self.restore {
|
||||
PhraseMode::Import
|
||||
@@ -436,7 +475,7 @@ impl OnboardingContent {
|
||||
if !self.pass.is_empty() && self.pass != self.pass2 {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Passwords don't match")
|
||||
RichText::new(t!("goblin.onboarding.wallet.passwords_no_match"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
@@ -453,14 +492,15 @@ impl OnboardingContent {
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
let restore = self.mnemonic_setup.mnemonic.mode() == PhraseMode::Import;
|
||||
let words_title = if restore {
|
||||
t!("goblin.onboarding.words.title_restore")
|
||||
} else {
|
||||
t!("goblin.onboarding.words.title_create")
|
||||
};
|
||||
self.step_header(
|
||||
ui,
|
||||
"STEP 2 OF 3 · WALLET",
|
||||
if restore {
|
||||
"Enter your seed words"
|
||||
} else {
|
||||
"Write these words down"
|
||||
},
|
||||
&t!("goblin.onboarding.words.kicker"),
|
||||
&words_title,
|
||||
Step::WalletSetup,
|
||||
);
|
||||
if restore {
|
||||
@@ -478,12 +518,9 @@ impl OnboardingContent {
|
||||
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),
|
||||
RichText::new(t!("goblin.onboarding.words.write_down_hint"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
@@ -501,7 +538,7 @@ impl OnboardingContent {
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::chip(ui, "Paste", false).clicked() {
|
||||
if w::chip(ui, &t!("goblin.onboarding.words.paste"), false).clicked() {
|
||||
let data = ZeroingString::from(cb.get_string_from_buffer());
|
||||
self.mnemonic_setup.mnemonic.import(&data);
|
||||
}
|
||||
@@ -514,7 +551,7 @@ impl OnboardingContent {
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::chip(ui, "Scan QR", false).clicked() {
|
||||
if w::chip(ui, &t!("goblin.onboarding.words.scan_qr"), false).clicked() {
|
||||
self.scan_modal = Some(CameraScanContent::default());
|
||||
Modal::new(OB_PHRASE_SCAN_MODAL)
|
||||
.position(ModalPosition::CenterTop)
|
||||
@@ -527,8 +564,24 @@ impl OnboardingContent {
|
||||
);
|
||||
});
|
||||
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());
|
||||
} else {
|
||||
// Transient "Copied" feedback (the Build 82/89 pattern): a silent
|
||||
// copy of the recovery phrase reads as a dead button.
|
||||
let copied = matches!(self.words_copied, Some(at) if at.elapsed().as_millis() < 1500);
|
||||
if self.words_copied.is_some() {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
let label = if copied {
|
||||
format!("{} {}", CHECK, t!("goblin.receive.copied"))
|
||||
} else {
|
||||
t!("goblin.onboarding.words.copy_clipboard").to_string()
|
||||
};
|
||||
if w::chip(ui, &label, false).clicked() {
|
||||
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
|
||||
cb.vibrate_copy();
|
||||
self.words_copied = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
if !restore {
|
||||
ui.add_space(14.0);
|
||||
@@ -540,12 +593,12 @@ impl OnboardingContent {
|
||||
true
|
||||
};
|
||||
let label = if restore {
|
||||
"Restore wallet"
|
||||
t!("goblin.onboarding.words.restore_wallet")
|
||||
} else {
|
||||
"I wrote them down"
|
||||
t!("goblin.onboarding.words.wrote_them_down")
|
||||
};
|
||||
if ready {
|
||||
if w::big_action(ui, label, false).clicked() {
|
||||
if w::big_action(ui, &label, false).clicked() {
|
||||
if restore {
|
||||
self.create_wallet(wallets);
|
||||
} else {
|
||||
@@ -554,7 +607,7 @@ impl OnboardingContent {
|
||||
}
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("Fill every word — tap a word to edit it, or paste the phrase.")
|
||||
RichText::new(t!("goblin.onboarding.words.fill_every_word"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -569,27 +622,32 @@ impl OnboardingContent {
|
||||
cb: &dyn PlatformCallbacks,
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Now prove it", Step::Words);
|
||||
self.step_header(
|
||||
ui,
|
||||
&t!("goblin.onboarding.confirm.kicker"),
|
||||
&t!("goblin.onboarding.confirm.title"),
|
||||
Step::Words,
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Enter the words you just wrote down. Tap a word to type it.")
|
||||
RichText::new(t!("goblin.onboarding.confirm.enter_hint"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
self.mnemonic_setup.word_list_ui(ui, true);
|
||||
ui.add_space(14.0);
|
||||
if w::chip(ui, "Paste", false).clicked() {
|
||||
if w::chip(ui, &t!("goblin.onboarding.confirm.paste"), false).clicked() {
|
||||
let data = ZeroingString::from(cb.get_string_from_buffer());
|
||||
self.mnemonic_setup.mnemonic.import(&data);
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
if !self.mnemonic_setup.mnemonic.has_empty_or_invalid() {
|
||||
if w::big_action(ui, "Create wallet", false).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.onboarding.confirm.create_wallet"), false).clicked() {
|
||||
self.create_wallet(wallets);
|
||||
}
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("Keep going — every word, in order.")
|
||||
RichText::new(t!("goblin.onboarding.confirm.keep_going"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -650,10 +708,20 @@ impl OnboardingContent {
|
||||
self.error = None;
|
||||
self.step = Step::Identity;
|
||||
}
|
||||
Err(e) => self.error = Some(format!("Couldn't open the wallet: {:?}", e)),
|
||||
Err(e) => {
|
||||
self.error = Some(
|
||||
t!("goblin.onboarding.errors.cant_open", err => format!("{:?}", e))
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.error = Some(format!("Couldn't create the wallet: {:?}", e)),
|
||||
Err(e) => {
|
||||
self.error = Some(
|
||||
t!("goblin.onboarding.errors.cant_create", err => format!("{:?}", e))
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,13 +731,13 @@ impl OnboardingContent {
|
||||
let t = theme::tokens();
|
||||
// No back from here: the wallet exists now.
|
||||
ui.label(
|
||||
RichText::new("STEP 3 OF 3 · IDENTITY")
|
||||
RichText::new(t!("goblin.onboarding.identity.kicker"))
|
||||
.font(fonts::kicker())
|
||||
.color(t.text_mute),
|
||||
);
|
||||
ui.add_space(18.0);
|
||||
ui.label(
|
||||
RichText::new("Your payment identity")
|
||||
RichText::new(t!("goblin.onboarding.identity.title"))
|
||||
.font(FontId::new(26.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
@@ -681,6 +749,13 @@ impl OnboardingContent {
|
||||
.as_ref()
|
||||
.map(|s| (s.npub(), s.is_connected()))
|
||||
.unwrap_or((String::new(), false));
|
||||
// The claimed @name (bare), if any — so the identity card shows the name
|
||||
// instead of the npub once a username is registered.
|
||||
let claimed_name = service
|
||||
.as_ref()
|
||||
.and_then(|s| s.identity.read().nip05.clone())
|
||||
.and_then(|n| n.split('@').next().map(|s| s.to_string()))
|
||||
.filter(|n| !n.is_empty());
|
||||
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
@@ -689,29 +764,50 @@ impl OnboardingContent {
|
||||
// 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);
|
||||
// Key still generating: a fixed-seed gradient placeholder.
|
||||
w::gradient_avatar(ui, "goblin", 44.0);
|
||||
} 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()
|
||||
// Once claimed, show the @name (with a check) instead of the npub
|
||||
// so the user can SEE the username applied.
|
||||
if let Some(name) = &claimed_name {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 5.0;
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.font(FontId::new(16.0, fonts::bold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(crate::gui::icons::SEAL_CHECK)
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.pos),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
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"
|
||||
let short = if npub.len() > 20 {
|
||||
format!("{}…{}", &npub[..12], &npub[npub.len() - 6..])
|
||||
} else if npub.is_empty() {
|
||||
t!("goblin.onboarding.identity.key_being_made").to_string()
|
||||
} else {
|
||||
"connecting over Nym…"
|
||||
npub.clone()
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(short)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
}
|
||||
ui.label(
|
||||
// Relay-gated readiness: "connected over Nym" only once a
|
||||
// relay is actually live, not merely when the tunnel is warm.
|
||||
RichText::new(if crate::tor::transport_ready() {
|
||||
t!("goblin.onboarding.identity.connected_nym")
|
||||
} else {
|
||||
t!("goblin.onboarding.identity.connecting_nym")
|
||||
})
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
@@ -720,24 +816,9 @@ impl OnboardingContent {
|
||||
});
|
||||
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),
|
||||
RichText::new(t!("goblin.onboarding.identity.fresh_key_blurb"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
@@ -752,8 +833,13 @@ impl OnboardingContent {
|
||||
self.claim.message = Some(msg.to_string());
|
||||
}
|
||||
ClaimMsg::Registered(nip05) => {
|
||||
self.claim.message =
|
||||
Some(format!("You're @{}", nip05.split('@').next().unwrap_or("")));
|
||||
self.claim.message = Some(
|
||||
t!(
|
||||
"goblin.onboarding.identity.youre",
|
||||
name => nip05.split('@').next().unwrap_or("")
|
||||
)
|
||||
.to_string(),
|
||||
);
|
||||
self.claim.available = Some(true);
|
||||
if let Some(s) = wallet.nostr_service() {
|
||||
{
|
||||
@@ -763,6 +849,9 @@ impl OnboardingContent {
|
||||
}
|
||||
s.save_identity();
|
||||
}
|
||||
// Publish kind 0 now so the just-claimed name is visible to
|
||||
// others over the relay without waiting for the next app start.
|
||||
wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile);
|
||||
}
|
||||
ClaimMsg::Released => {}
|
||||
ClaimMsg::Error(e) => {
|
||||
@@ -775,36 +864,30 @@ impl OnboardingContent {
|
||||
.nostr_service()
|
||||
.map(|s| s.identity.read().nip05.is_some())
|
||||
.unwrap_or(false);
|
||||
if !registered {
|
||||
if self.import.is_some() {
|
||||
// Returning user is swapping the random key for their existing identity.
|
||||
self.import_ui(ui, &wallet, cb);
|
||||
} else if !registered {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.label(
|
||||
RichText::new("Pick a username — optional")
|
||||
RichText::new(t!("goblin.onboarding.identity.pick_username"))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"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),
|
||||
RichText::new(t!("goblin.onboarding.identity.username_blurb"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
w::field_well(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
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")
|
||||
.hint_text(t!("goblin.onboarding.identity.username_field_hint"))
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.claim.input, cb);
|
||||
@@ -828,35 +911,89 @@ impl OnboardingContent {
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let name = self.claim.input.trim().to_lowercase();
|
||||
let valid = name.len() >= 3 && name.len() <= 30;
|
||||
let valid = name.len() >= 3 && name.len() <= 20;
|
||||
if self.claim.checking {
|
||||
ui.horizontal(|ui| {
|
||||
View::small_loading_spinner(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new("Working…").color(t.surface_text_dim));
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.onboarding.identity.working"))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
} else {
|
||||
ui.add_enabled_ui(valid && connected, |ui| {
|
||||
if w::big_action_on_card(ui, "Claim username").clicked() {
|
||||
if w::big_action_on_card(
|
||||
ui,
|
||||
&t!("goblin.onboarding.identity.claim_username"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
start_claim_flow(&mut self.claim, &name, &wallet);
|
||||
}
|
||||
});
|
||||
if !connected {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Available once the mixnet connects — or skip and claim later.",
|
||||
)
|
||||
RichText::new(t!(
|
||||
"goblin.onboarding.identity.available_when_connected"
|
||||
))
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
// Returning user? Let them restore their existing identity (nsec or a
|
||||
// .backup file) instead of claiming a fresh name on the random key.
|
||||
let import_resp = ui
|
||||
.add(
|
||||
egui::Label::new(
|
||||
RichText::new(t!("goblin.onboarding.identity.import_existing"))
|
||||
.font(FontId::new(13.0, fonts::semibold()))
|
||||
.color(t.accent),
|
||||
)
|
||||
.sense(Sense::click()),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if import_resp.clicked() {
|
||||
self.import = Some(OnbImport::default());
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
} else {
|
||||
ui.add_space(2.0);
|
||||
// Claimed: show a clear success confirmation so the user knows the
|
||||
// username stuck before they tap through to the wallet.
|
||||
let claimed = claimed_name.clone().unwrap_or_default();
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
ui.label(
|
||||
RichText::new(crate::gui::icons::SEAL_CHECK)
|
||||
.font(FontId::new(22.0, fonts::regular()))
|
||||
.color(t.pos),
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!(
|
||||
"goblin.onboarding.identity.claimed_title",
|
||||
name => &claimed
|
||||
))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.onboarding.identity.claimed_blurb"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
if !connected {
|
||||
@@ -865,16 +1002,209 @@ impl OnboardingContent {
|
||||
}
|
||||
|
||||
let main_label = if registered {
|
||||
"Open my wallet"
|
||||
t!("goblin.onboarding.identity.open_wallet")
|
||||
} else {
|
||||
"Skip for now"
|
||||
t!("goblin.onboarding.identity.skip_for_now")
|
||||
};
|
||||
if w::big_action(ui, main_label, false).clicked() {
|
||||
if w::big_action(ui, &main_label, false).clicked() {
|
||||
return Some(wallet);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Onboarding identity-import sub-flow: paste an nsec or pick a `.backup`
|
||||
/// file to swap the freshly-generated random key for the user's existing
|
||||
/// identity (keeping their npub and any claimed username). Reuses the wallet
|
||||
/// password the user just set; a sealed `.backup` may carry its own password.
|
||||
fn import_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
let t = theme::tokens();
|
||||
// Poll the worker first, WITHOUT holding a borrow across the reset below.
|
||||
if self.import.as_ref().map(|i| i.stage) == Some(1) {
|
||||
let res = self.import.as_ref().unwrap().result.lock().unwrap().take();
|
||||
if let Some(res) = res {
|
||||
match res {
|
||||
// Identity replaced: drop the sub-flow; the identity card and the
|
||||
// claim/success state re-render from the new service next frame.
|
||||
Ok(_) => {
|
||||
self.import = None;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let imp = self.import.as_mut().unwrap();
|
||||
imp.error = e;
|
||||
imp.stage = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let wallet_pass = self.pass.clone();
|
||||
let imp = self.import.as_mut().unwrap();
|
||||
let mut close = false;
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
match imp.stage {
|
||||
1 => {
|
||||
ui.horizontal(|ui| {
|
||||
View::small_loading_spinner(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.importing"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
2 => {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.import_failed"))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.neg),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(&imp.error)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.onboarding.identity.import_title"))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.onboarding.identity.import_blurb"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
// Native ".backup file" picker. Desktop returns the path now;
|
||||
// Android resolves it asynchronously (poll picked_file()).
|
||||
if imp.picking {
|
||||
if let Some(path) = cb.picked_file() {
|
||||
imp.picking = false;
|
||||
if !path.is_empty() {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => imp.nsec = contents.trim().to_string(),
|
||||
Err(_) => {
|
||||
imp.error =
|
||||
t!("goblin.settings.backup_read_failed").to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
if w::big_action_on_card(ui, &t!("goblin.settings.choose_backup_file"))
|
||||
.clicked()
|
||||
{
|
||||
imp.error.clear();
|
||||
match cb.pick_file() {
|
||||
Some(path) if !path.is_empty() => {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => imp.nsec = contents.trim().to_string(),
|
||||
Err(_) => {
|
||||
imp.error =
|
||||
t!("goblin.settings.backup_read_failed").to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Empty string = Android async pick in flight.
|
||||
Some(_) => imp.picking = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_import_nsec"))
|
||||
.focus(false)
|
||||
.hint_text(t!("goblin.settings.import_nsec_hint"))
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut imp.nsec, cb);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_import_bpw"))
|
||||
.focus(false)
|
||||
.hint_text(t!("goblin.settings.backup_password_hint"))
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut imp.backup_password, cb);
|
||||
});
|
||||
if !imp.error.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(&imp.error)
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let pasted = imp.nsec.trim();
|
||||
// Only an nsec paste or a sealed .backup file — nothing else.
|
||||
let armed =
|
||||
pasted.starts_with("nsec1") || NostrIdentity::is_encrypted_backup(pasted);
|
||||
ui.horizontal(|ui| {
|
||||
let half = (ui.available_width() - 10.0) / 2.0;
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::big_action_on_card(ui, &t!("goblin.settings.cancel"))
|
||||
.clicked()
|
||||
{
|
||||
close = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
ui.add_enabled_ui(armed, |ui| {
|
||||
if w::big_action(ui, &t!("goblin.settings.import_btn"), false)
|
||||
.clicked()
|
||||
{
|
||||
imp.stage = 1;
|
||||
let slot = imp.result.clone();
|
||||
let nsec = std::mem::take(&mut imp.nsec);
|
||||
let bpw = std::mem::take(&mut imp.backup_password);
|
||||
let bpw = if bpw.is_empty() { None } else { Some(bpw) };
|
||||
let wallet = wallet.clone();
|
||||
let pass = wallet_pass.clone();
|
||||
std::thread::spawn(move || {
|
||||
let res = wallet.import_nostr_identity(nsec, pass, bpw);
|
||||
*slot.lock().unwrap() = Some(res);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if close {
|
||||
self.import = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recovery-phrase QR scan modal content.
|
||||
fn scan_modal_ui(&mut self, ui: &mut egui::Ui, _: &Modal, cb: &dyn PlatformCallbacks) {
|
||||
if let Some(content) = self.scan_modal.as_mut() {
|
||||
|
||||
@@ -27,34 +27,14 @@ 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 {
|
||||
/// A custom-picture avatar: the texture drawn to fill the circle. Names never
|
||||
/// affect the avatar — claimed and anonymous identities render identically.
|
||||
pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, _name: &str, size: f32) -> 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);
|
||||
let rounding = eframe::epaint::CornerRadius::same((rect.width() / 2.0) as u8);
|
||||
egui::Image::new(tex)
|
||||
.corner_radius(rounding)
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.fit_to_exact_size(rect.size())
|
||||
.paint_at(ui, rect);
|
||||
resp
|
||||
}
|
||||
@@ -65,80 +45,42 @@ pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response
|
||||
/// 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);
|
||||
paint_gradient(ui, id, 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());
|
||||
/// Paint the pubkey-seeded grinmark gradient into `rect` (rasterized at 2x,
|
||||
/// cached by egui via the `uri`).
|
||||
fn paint_gradient(ui: &mut Ui, id: &str, rect: egui::Rect) {
|
||||
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);
|
||||
let px = (rect.width() * 2.0) as u32;
|
||||
let svg = super::identicon::gradient_avatar_svg(&hex, px, "");
|
||||
let uri = format!("bytes://gobavatar-{}-{}.svg", hex, rect.width() 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))
|
||||
.corner_radius(CornerRadius::same((rect.width() / 2.0) as u8))
|
||||
.fit_to_exact_size(rect.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.
|
||||
/// pubkey-seeded grinmark gradient for everyone, named or anonymous — names
|
||||
/// never affect the avatar. When no pubkey is known (last resort) the name
|
||||
/// seeds the gradient instead, so the tile is still deterministic. `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),
|
||||
Some(t) => avatar_tex(ui, t, name, size),
|
||||
None if !id.is_empty() => gradient_avatar(ui, id, size),
|
||||
None => gradient_avatar(ui, name, size),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +138,7 @@ pub fn amount_text_centered_shifted(
|
||||
.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()),
|
||||
FontId::new(sz * 0.46, fonts::semibold()),
|
||||
mark_ink,
|
||||
);
|
||||
num.size().x + 1.0 + mark.size().x
|
||||
@@ -221,7 +163,7 @@ pub fn amount_text_centered_shifted(
|
||||
ui.add_space(1.0);
|
||||
ui.label(
|
||||
RichText::new(TSU)
|
||||
.font(FontId::new(size * 0.4, fonts::medium()))
|
||||
.font(FontId::new(size * 0.46, fonts::semibold()))
|
||||
.color(mark_ink),
|
||||
);
|
||||
});
|
||||
@@ -309,12 +251,20 @@ 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 {
|
||||
let (mut fill, mut ink, mut 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 {
|
||||
// Inside `add_enabled_ui(false)` the button must LOOK disabled too, so a
|
||||
// blocked action (e.g. Review while over balance) never reads as a live CTA.
|
||||
let enabled = ui.is_enabled();
|
||||
if !enabled {
|
||||
fill = fill.gamma_multiply(0.35);
|
||||
ink = ink.gamma_multiply(0.45);
|
||||
stroke.color = stroke.color.gamma_multiply(0.45);
|
||||
}
|
||||
let visual_fill = if enabled && resp.hovered() && !secondary {
|
||||
t.accent_dark
|
||||
} else {
|
||||
fill
|
||||
@@ -382,6 +332,43 @@ pub fn big_action_on_card_ink(ui: &mut Ui, label: &str, ink: Color32) -> Respons
|
||||
resp
|
||||
}
|
||||
|
||||
/// A full-width outlined action with an icon to the left of its label, bordered
|
||||
/// in a tint of `ink` (so it reads "around the same color" as the text). Used
|
||||
/// for the wallet-management cluster at the foot of Settings — switch / lock /
|
||||
/// advanced — where each action stands on its own rather than in a card.
|
||||
pub fn outlined_icon_action(ui: &mut Ui, icon: &str, label: &str, ink: Color32) -> Response {
|
||||
let desired = Vec2::new(ui.available_width(), 50.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
|
||||
let border = ink.gamma_multiply(if resp.hovered() { 0.9 } else { 0.55 });
|
||||
let fill = if resp.hovered() {
|
||||
ink.gamma_multiply(0.10)
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(14),
|
||||
fill,
|
||||
Stroke::new(1.5, border),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.left_center() + Vec2::new(18.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
icon,
|
||||
FontId::new(18.0, fonts::regular()),
|
||||
ink,
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.left_center() + Vec2::new(46.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
label,
|
||||
FontId::new(15.0, fonts::semibold()),
|
||||
ink,
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A pill/chip; returns the click response. `active` paints it inverted.
|
||||
pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
|
||||
let t = theme::tokens();
|
||||
@@ -409,35 +396,9 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
|
||||
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.
|
||||
/// Paint a QR code for `text` with the goblin mark centered. Always dark modules
|
||||
/// on a white plate, whatever the theme — inverted codes fail to decode in many
|
||||
/// scanners. Encoded synchronously each frame; modules are plain painter rects.
|
||||
pub fn qr_code(ui: &mut Ui, text: &str, size: f32) {
|
||||
let plate = Color32::WHITE;
|
||||
let ink = Color32::from_rgb(0x0E, 0x0E, 0x0C);
|
||||
@@ -452,10 +413,9 @@ pub fn qr_code(ui: &mut Ui, text: &str, size: f32) {
|
||||
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.
|
||||
// Full cells, no inter-module gap: at receive-card density (~4.5px cells) even
|
||||
// a 0.5px gap fragments the finder patterns and scanners fail. Round corners
|
||||
// only when cells are large enough that the notching can't matter.
|
||||
let radius = if cell >= 6.0 { (cell * 0.3) as u8 } else { 0 };
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
@@ -469,11 +429,9 @@ pub fn qr_code(ui: &mut Ui, text: &str, size: f32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// Goblin mark on a yellow backing square in the center, 19% footprint (larger
|
||||
// obscures too many modules for a reliable decode). Yellow reads as "light" to
|
||||
// a scanner like white, so the covered center is recovered by the High ECC.
|
||||
let t = theme::tokens();
|
||||
let backing = size * 0.19;
|
||||
let b_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing));
|
||||
@@ -504,17 +462,152 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut 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) {
|
||||
/// `updating` marks a zero balance that is only zero because funds are in
|
||||
/// flight or the first sync is still running.
|
||||
/// Honest subline shown under the balance figure. A wallet that can't reach a
|
||||
/// node must never present a bare `0` (or a silently-stale number) as if it were
|
||||
/// a live, confirmed balance.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum BalanceSubline {
|
||||
/// Nothing to add: the shown balance is live and non-zero.
|
||||
None,
|
||||
/// Balance reads 0 while a sync/first-scan is in progress or funds are in
|
||||
/// flight — say "updating", not "empty".
|
||||
Updating,
|
||||
/// Balance reads 0 and the node is unreachable with nothing cached — say
|
||||
/// "can't reach node", never a bare 0.
|
||||
Unreachable,
|
||||
/// A cached (last-known) balance is shown but the node is currently
|
||||
/// unreachable — flag it as possibly stale.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// Pure decision for the balance subline. `updating` means a sync is in progress
|
||||
/// (or funds are in flight); `error` means the wallet currently can't reach a
|
||||
/// node. Priority: updating > unreachable > stale.
|
||||
pub fn balance_subline(total: u64, updating: bool, error: bool) -> BalanceSubline {
|
||||
if total == 0 && updating {
|
||||
BalanceSubline::Updating
|
||||
} else if total == 0 && error {
|
||||
BalanceSubline::Unreachable
|
||||
} else if error {
|
||||
BalanceSubline::Stale
|
||||
} else {
|
||||
BalanceSubline::None
|
||||
}
|
||||
}
|
||||
|
||||
/// What the fiat subline should render under the balance. `None` (pairing off)
|
||||
/// draws no line at all; otherwise the line is honest about its state and never
|
||||
/// paints a stale rate as if current.
|
||||
pub enum FiatLine {
|
||||
/// A ready "≈ … · 1ツ = …" line built from a fresh rate.
|
||||
Text(String),
|
||||
/// A live fetch is in flight; show a subtle placeholder, not a number.
|
||||
Loading,
|
||||
/// The rate could not be fetched; say so rather than show an old value.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub fn balance_hero(
|
||||
ui: &mut Ui,
|
||||
total: u64,
|
||||
spendable: u64,
|
||||
updating: bool,
|
||||
error: bool,
|
||||
sync_pct: u8,
|
||||
fiat: Option<FiatLine>,
|
||||
size: f32,
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
// Centered to match the Pay amount and the empty-state below it.
|
||||
// Headline is the TOTAL the wallet holds — same number GRIM shows — so a
|
||||
// wallet mid-confirmation doesn't look empty.
|
||||
ui.vertical_centered(|ui| kicker(ui, "Balance"));
|
||||
ui.add_space(6.0);
|
||||
amount_text_centered(ui, &amount_str(atomic), size);
|
||||
if let Some(fiat) = fiat {
|
||||
amount_text_centered(ui, &amount_str(total), size);
|
||||
// When some of it can't be spent yet (a payment still confirming, ~10 blocks),
|
||||
// say how much is available vs confirming so a failed send explains itself.
|
||||
if total > spendable {
|
||||
let confirming = total - spendable;
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(fiat)
|
||||
RichText::new(format!(
|
||||
"{}{} available · {}{} confirming",
|
||||
amount_str(spendable),
|
||||
TSU,
|
||||
amount_str(confirming),
|
||||
TSU
|
||||
))
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
// A stark 0 (or a stale number) reads as "funds vanished". Pick the honest
|
||||
// subline: still-updating, node-unreachable, or last-known-balance. See
|
||||
// [`balance_subline`] for the pure state machine.
|
||||
match balance_subline(total, updating, error) {
|
||||
BalanceSubline::Updating => {
|
||||
let label = if (1..100).contains(&sync_pct) {
|
||||
format!("{} {sync_pct}%", t!("goblin.home.balance_updating"))
|
||||
} else {
|
||||
t!("goblin.home.balance_updating").to_string()
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::Unreachable => {
|
||||
// Node unreachable and nothing cached yet: a bare 0 would claim the
|
||||
// wallet is empty. Say the truth so the user switches nodes instead
|
||||
// of assuming funds vanished.
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.home.cant_reach_node"))
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.neg),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::Stale => {
|
||||
// A cached balance is shown but we can't currently reach a node:
|
||||
// flag it as possibly stale rather than presenting it as live.
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.home.balance_stale"))
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::None => {}
|
||||
}
|
||||
if let Some(fiat) = fiat {
|
||||
// Loading and Unavailable both render a subtle dim line — never a stale
|
||||
// number. While loading, nudge egui to re-poll so the rate pops in once the
|
||||
// live fetch lands even if the view is otherwise idle (bounded to the time
|
||||
// the balance is actually on screen — not a background timer).
|
||||
let text = match fiat {
|
||||
FiatLine::Text(s) => s,
|
||||
FiatLine::Loading => {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(300));
|
||||
"≈ …".to_string()
|
||||
}
|
||||
FiatLine::Unavailable => t!("goblin.home.fiat_unavailable").to_string(),
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(text)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
@@ -522,21 +615,27 @@ pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// An activity row: avatar, title, subtitle, signed amount.
|
||||
/// An activity row: avatar, a left title/message column that truncates, and a
|
||||
/// right column with the signed amount over the date/time. `time` is the
|
||||
/// right-side timestamp (empty draws no time line — e.g. a canceled tx).
|
||||
/// Returns the row click response.
|
||||
pub fn activity_row(
|
||||
ui: &mut Ui,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
hue: usize,
|
||||
note: &str,
|
||||
time: &str,
|
||||
id: &str,
|
||||
amount: &str,
|
||||
incoming: bool,
|
||||
canceled: bool,
|
||||
system: bool,
|
||||
tex: Option<&egui::TextureHandle>,
|
||||
) -> Response {
|
||||
let t = theme::tokens();
|
||||
let row_h = 60.0;
|
||||
// A touch taller than a single-line row so the amount can sit centered
|
||||
// against the two-line title/subtitle stack with clear breathing room
|
||||
// above and below instead of colliding with the title baseline.
|
||||
let row_h = 64.0;
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click());
|
||||
let mut content = ui.new_child(
|
||||
@@ -562,36 +661,80 @@ pub fn activity_row(
|
||||
t.text,
|
||||
);
|
||||
} else {
|
||||
avatar_any(ui, title, id, 40.0, hue, tex);
|
||||
avatar_any(ui, title, id, 40.0, 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(),
|
||||
);
|
||||
});
|
||||
// Right column FIRST so the left title/message column is bounded to the
|
||||
// remaining width and truncates cleanly. The amount sits on top with the
|
||||
// date/time right-aligned directly beneath it; a row with no timestamp
|
||||
// (a canceled tx) draws no time line at all.
|
||||
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 }),
|
||||
);
|
||||
let amt_ink = if canceled {
|
||||
t.text_dim
|
||||
} else if incoming {
|
||||
t.pos
|
||||
} else {
|
||||
t.text
|
||||
};
|
||||
let amt_font = FontId::new(15.0, fonts::mono_semibold());
|
||||
let time_font = FontId::new(13.0, fonts::regular());
|
||||
let amt_g = (!amount.is_empty()).then(|| {
|
||||
ui.painter()
|
||||
.layout_no_wrap(amount.to_string(), amt_font, amt_ink)
|
||||
});
|
||||
let time_g = (!time.is_empty()).then(|| {
|
||||
ui.painter()
|
||||
.layout_no_wrap(time.to_string(), time_font, t.text_dim)
|
||||
});
|
||||
let col_w = amt_g
|
||||
.as_ref()
|
||||
.map(|g| g.size().x)
|
||||
.unwrap_or(0.0)
|
||||
.max(time_g.as_ref().map(|g| g.size().x).unwrap_or(0.0));
|
||||
if col_w > 0.0 {
|
||||
let amt_h = amt_g.as_ref().map(|g| g.size().y).unwrap_or(0.0);
|
||||
let time_h = time_g.as_ref().map(|g| g.size().y).unwrap_or(0.0);
|
||||
let col_h = amt_h + if time_h > 0.0 { 2.0 + time_h } else { 0.0 };
|
||||
ui.allocate_ui_with_layout(
|
||||
Vec2::new(col_w, col_h),
|
||||
Layout::top_down(Align::Max),
|
||||
|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
if let Some(g) = amt_g {
|
||||
let (r, _) = ui.allocate_exact_size(g.size(), Sense::hover());
|
||||
ui.painter().galley(r.min, g, amt_ink);
|
||||
}
|
||||
if let Some(g) = time_g {
|
||||
let (r, _) = ui.allocate_exact_size(g.size(), Sense::hover());
|
||||
ui.painter().galley(r.min, g, t.text_dim);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
// Remaining width to the left: the counterparty/title on top, the
|
||||
// message pinned left and truncated with an ellipsis beneath it.
|
||||
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(),
|
||||
);
|
||||
if !note.is_empty() {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(note)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Divider.
|
||||
@@ -706,7 +849,11 @@ pub fn send_receive(ui: &mut Ui) -> (bool, bool) {
|
||||
}
|
||||
|
||||
/// A simple numeric keypad. Mutates `amount` string. Returns true if changed.
|
||||
pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
|
||||
pub fn numpad(
|
||||
ui: &mut Ui,
|
||||
amount: &mut String,
|
||||
cb: &dyn crate::gui::platform::PlatformCallbacks,
|
||||
) -> bool {
|
||||
let t = theme::tokens();
|
||||
let mut changed = false;
|
||||
let keys = [
|
||||
@@ -719,7 +866,7 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
|
||||
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).
|
||||
// columns more breathing room (payment-app-style).
|
||||
let pad_w = ui.available_width().min(332.0);
|
||||
let key_w = (pad_w - 2.0 * gap) / 3.0;
|
||||
let side = ((ui.available_width() - pad_w) / 2.0).max(0.0);
|
||||
@@ -755,8 +902,16 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
|
||||
col,
|
||||
);
|
||||
if resp.clicked() {
|
||||
let before = amount.clone();
|
||||
apply_key(amount, k);
|
||||
changed = true;
|
||||
if *amount == before {
|
||||
// A no-op key — a second '.', a '0' on a leading zero, the
|
||||
// 9-decimal cap, or backspace on empty. Nudge with a short
|
||||
// error haptic instead of silently doing nothing.
|
||||
cb.vibrate_error();
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -820,12 +975,6 @@ pub fn apply_key(amount: &mut String, key: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -918,10 +1067,40 @@ impl HoldToSend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten a long key/address for display (8…6).
|
||||
pub fn short_key(key: &str) -> String {
|
||||
if key.len() <= 16 {
|
||||
return key.to_string();
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BalanceSubline, balance_subline};
|
||||
|
||||
// A live, non-zero balance needs no subline.
|
||||
#[test]
|
||||
fn live_balance_has_no_subline() {
|
||||
assert_eq!(balance_subline(1_000, false, false), BalanceSubline::None);
|
||||
}
|
||||
|
||||
// Zero while syncing / funds in flight is "updating", not "empty".
|
||||
#[test]
|
||||
fn zero_while_updating_says_updating() {
|
||||
assert_eq!(balance_subline(0, true, false), BalanceSubline::Updating);
|
||||
}
|
||||
|
||||
// Zero with an unreachable node and nothing cached must say so, never a
|
||||
// bare 0 that reads as "wallet empty" (the silent-zero incident).
|
||||
#[test]
|
||||
fn zero_with_node_error_says_unreachable() {
|
||||
assert_eq!(balance_subline(0, false, true), BalanceSubline::Unreachable);
|
||||
}
|
||||
|
||||
// A cached balance shown during a node outage is flagged stale, not passed
|
||||
// off as a live figure.
|
||||
#[test]
|
||||
fn cached_balance_with_error_is_stale() {
|
||||
assert_eq!(balance_subline(500, false, true), BalanceSubline::Stale);
|
||||
}
|
||||
|
||||
// Updating wins over error while the balance is still zero: a fresh switch
|
||||
// to a new node shows progress, not a scary red banner, until it errors.
|
||||
#[test]
|
||||
fn updating_takes_priority_over_error_at_zero() {
|
||||
assert_eq!(balance_subline(0, true, true), BalanceSubline::Updating);
|
||||
}
|
||||
format!("{}…{}", &key[..8], &key[key.len() - 6..])
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ impl NetworkContent {
|
||||
}
|
||||
|
||||
/// Content to draw when node is disabled.
|
||||
fn disabled_node_ui(ui: &mut egui::Ui) {
|
||||
pub fn disabled_node_ui(ui: &mut egui::Ui) {
|
||||
View::center_content(ui, 156.0, |ui| {
|
||||
let text = t!("network.disabled_server", "dots" => DOTS_THREE_OUTLINE_VERTICAL);
|
||||
ui.label(
|
||||
|
||||
@@ -28,7 +28,6 @@ pub struct SettingsContent {
|
||||
interface_settings: InterfaceSettingsContent,
|
||||
/// Network communication settings.
|
||||
network_settings: NetworkSettingsContent,
|
||||
// tor_settings: TorSettingsContent,
|
||||
}
|
||||
|
||||
impl Default for SettingsContent {
|
||||
@@ -36,7 +35,6 @@ impl Default for SettingsContent {
|
||||
Self {
|
||||
interface_settings: InterfaceSettingsContent::default(),
|
||||
network_settings: NetworkSettingsContent::default(),
|
||||
//tor_settings: TorSettingsContent::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,21 +108,5 @@ impl SettingsContent {
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Do not show Tor settings on Android.
|
||||
// let os = OperatingSystem::from_target_os();
|
||||
// let show_tor = os != OperatingSystem::Android;
|
||||
// if show_tor {
|
||||
// View::horizontal_line(ui, Colors::stroke());
|
||||
// ui.add_space(6.0);
|
||||
//
|
||||
// View::sub_title(ui, format!("{} {}", CIRCLE_HALF, t!("transport.tor_network")));
|
||||
// View::horizontal_line(ui, Colors::stroke());
|
||||
// ui.add_space(6.0);
|
||||
//
|
||||
// // Show Tor settings.
|
||||
// self.tor_settings.ui(ui, cb);
|
||||
// ui.add_space(8.0);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,16 +703,12 @@ impl View {
|
||||
.fit_to_exact_size(egui::vec2(150.0, 150.0))
|
||||
.ui(ui);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("GOBLIN")
|
||||
.size(24.0)
|
||||
.color(Colors::white_or_black(true)),
|
||||
);
|
||||
ui.add_space(-2.0);
|
||||
ui.add_space(6.0);
|
||||
// Build number only — the "GOBLIN" wordmark was redundant with the mark
|
||||
// above and the title bar, so it's dropped. Kept small and quiet.
|
||||
ui.label(
|
||||
RichText::new(format!("Build {}", crate::BUILD))
|
||||
.size(16.0)
|
||||
.size(13.0)
|
||||
.color(Colors::title(false)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::AppConfig;
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::{
|
||||
ARROW_LEFT, BOOKMARKS, CALENDAR_CHECK, CLOUD_ARROW_DOWN, COMPUTER_TOWER, GEAR, GEAR_FINE,
|
||||
GLOBE, GLOBE_SIMPLE, LOCK_KEY, NOTEPAD, PLUS, SIDEBAR_SIMPLE, SUITCASE,
|
||||
GLOBE_SIMPLE, LOCK_KEY, NOTEPAD, PLUS, SIDEBAR_SIMPLE, SUITCASE,
|
||||
};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::views::goblin::onboarding::OnboardingContent;
|
||||
@@ -202,6 +202,18 @@ impl ContentContainer for WalletsContent {
|
||||
// keeps it through its identity step, when the new wallet is already
|
||||
// open but not yet selected).
|
||||
let onboarding_active = !showing_wallet && self.onboarding_active();
|
||||
// Keep the Android status-bar icons readable against whatever paints the
|
||||
// top strip. The GRIM screens (wallet list, app settings, wallet creation)
|
||||
// leave the bright accent-yellow `title_panel_bg` showing under the status
|
||||
// bar, which needs DARK icons. Onboarding, though, paints its OWN dark
|
||||
// full-bleed surface (no title panel), so it needs theme-appropriate icons
|
||||
// (white on the dark surface) — forcing dark there made them black-on-black.
|
||||
// The Goblin wallet surface covers the inset itself and sets its per-tab flag.
|
||||
if !showing_wallet && !onboarding_active {
|
||||
crate::gui::theme::set_status_surface_yellow(true);
|
||||
} else if onboarding_active {
|
||||
crate::gui::theme::set_status_surface_yellow(false);
|
||||
}
|
||||
let dual_panel = is_dual_panel_mode(ui);
|
||||
let content_width = ui.available_width();
|
||||
let list_hidden = showing_settings
|
||||
@@ -210,11 +222,12 @@ impl ContentContainer for WalletsContent {
|
||||
|| self.wallets.list().is_empty()
|
||||
|| (showing_wallet && (!dual_panel || !AppConfig::show_wallets_at_dual_panel()));
|
||||
|
||||
// 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 {
|
||||
// Show the title panel everywhere except the full-bleed Goblin wallet
|
||||
// surface and onboarding (which carry their own headers). The wallet
|
||||
// list keeps GRIM's title bar — it puts the app-settings gear within
|
||||
// reach at the top-right and gives the list a proper header, painted in
|
||||
// the Pay-screen accent yellow.
|
||||
if !showing_wallet && !onboarding_active {
|
||||
self.title_ui(ui, dual_panel, cb);
|
||||
}
|
||||
|
||||
@@ -236,6 +249,13 @@ impl ContentContainer for WalletsContent {
|
||||
}
|
||||
});
|
||||
|
||||
// "Switch wallet" in the goblin settings asks to return to the chooser
|
||||
// without locking the current wallet (it stays open, so re-picking it is
|
||||
// instant). Locking is a separate, explicit action.
|
||||
if self.wallet_content.take_switch_request() {
|
||||
self.wallets.select(None);
|
||||
}
|
||||
|
||||
// Show wallet list tabs.
|
||||
let side_padding = View::TAB_ITEMS_PADDING + if View::is_desktop() { 0.0 } else { 4.0 };
|
||||
let tabs_margin = Margin {
|
||||
@@ -386,11 +406,11 @@ impl WalletsContent {
|
||||
}
|
||||
return false;
|
||||
} else if self.showing_wallet() {
|
||||
// Go back at stack or close wallet.
|
||||
// Go back at stack; on Home with nothing open, back is a no-op.
|
||||
// Leaving the wallet is intentional-only (switch/lock controls),
|
||||
// never a back fallback to the chooser.
|
||||
if self.wallet_content.can_back() {
|
||||
self.wallet_content.back(cb);
|
||||
} else {
|
||||
self.wallets.select(None);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -446,6 +466,17 @@ impl WalletsContent {
|
||||
if !Content::is_dual_panel_mode(ui.ctx()) && Content::is_network_panel_open() {
|
||||
Content::toggle_network_panel();
|
||||
}
|
||||
// Route a Goblin payment deep link (`goblin:` / `nostr:` pay URI) to the
|
||||
// send-review flow instead of the slatepack message handler: stash it for
|
||||
// the Goblin surface to open, and select/open the wallet with NO message
|
||||
// so the surface shows. Same destination as scanning a checkout QR.
|
||||
let data = match data {
|
||||
Some(d) if crate::nostr::payuri::is_pay_uri(&d) => {
|
||||
crate::set_pending_pay_uri(d);
|
||||
None
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
// Pass data to single wallet or show wallets selection.
|
||||
if wallets_size == 1 {
|
||||
let w = self.wallets.list()[0].clone();
|
||||
@@ -529,10 +560,10 @@ impl WalletsContent {
|
||||
});
|
||||
} else if show_wallet && !dual_panel {
|
||||
View::title_button_big(ui, ARROW_LEFT, |_| {
|
||||
// Same rule as system back: never fall back to the
|
||||
// chooser; on Home the arrow is a no-op.
|
||||
if self.wallet_content.can_back() {
|
||||
self.wallet_content.back(cb);
|
||||
} else {
|
||||
self.wallets.select(None);
|
||||
}
|
||||
});
|
||||
} else if self.creating_wallet() {
|
||||
@@ -552,10 +583,6 @@ impl WalletsContent {
|
||||
View::title_button_big(ui, list_icon, |_| {
|
||||
AppConfig::toggle_show_wallets_at_dual_panel();
|
||||
});
|
||||
} else if !Content::is_dual_panel_mode(ui.ctx()) {
|
||||
View::title_button_big(ui, GLOBE, |_| {
|
||||
Content::toggle_network_panel();
|
||||
});
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
@@ -574,35 +601,6 @@ 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()
|
||||
@@ -611,11 +609,8 @@ 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);
|
||||
// The app-settings gear now lives in the restored title bar
|
||||
// above; the list starts straight into the app logo.
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Show application logo and name.
|
||||
@@ -841,7 +836,7 @@ impl WalletsContent {
|
||||
// Show button to see update information.
|
||||
View::item_button(ui, CornerRadius::default(), NOTEPAD, None, || {
|
||||
self.changelog_content = Some(ChangelogContent::new(update.changelog.clone()));
|
||||
let title = format!("Grim {}", update.version);
|
||||
let title = format!("Goblin {}", update.version);
|
||||
Modal::new(ChangelogContent::MODAL_ID)
|
||||
.position(ModalPosition::Center)
|
||||
.title(title)
|
||||
@@ -863,7 +858,7 @@ impl WalletsContent {
|
||||
let ver_text = if let Some(size) = update.size.as_ref() {
|
||||
format!("{} {} ({} MB)", BOOKMARKS, update.version, size)
|
||||
} else {
|
||||
format!("{} {} > {}", BOOKMARKS, crate::VERSION, update.version)
|
||||
format!("{} build{} > {}", BOOKMARKS, crate::BUILD, update.version)
|
||||
};
|
||||
View::ellipsize_text(ui, ver_text, 15.0, Colors::text(false));
|
||||
ui.add_space(1.0);
|
||||
|
||||
@@ -16,7 +16,7 @@ use egui::scroll_area::ScrollBarVisibility;
|
||||
use egui::{Id, OpenUrl, RichText, ScrollArea};
|
||||
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::{BRACKETS_CURLY, GITHUB_LOGO, TELEGRAM_LOGO};
|
||||
use crate::gui::icons::{BOOK, GITHUB_LOGO, TELEGRAM_LOGO};
|
||||
use crate::gui::views::{Modal, View};
|
||||
|
||||
/// Application release changelog content.
|
||||
@@ -26,11 +26,11 @@ pub struct ChangelogContent {
|
||||
}
|
||||
|
||||
/// Endpoint for GitHub repository.
|
||||
const GITHUB_URL: &'static str = "https://github.com/GetGrin/grim";
|
||||
const GITHUB_URL: &'static str = "https://github.com/2ro/goblin";
|
||||
/// Endpoint for Telegram releases channel.
|
||||
const TELEGRAM_URL: &'static str = "https://t.me/grim_releases";
|
||||
/// Endpoint for git repository.
|
||||
const GIT_URL: &'static str = "https://code.gri.mw/GUI/grim";
|
||||
const TELEGRAM_URL: &'static str = "https://t.me/goblinfamily";
|
||||
/// Endpoint for project website.
|
||||
const GIT_URL: &'static str = "https://docs.goblin.st";
|
||||
|
||||
impl ChangelogContent {
|
||||
/// Create new content instance.
|
||||
@@ -114,7 +114,7 @@ impl ChangelogContent {
|
||||
columns[2].vertical_centered_justified(|ui| {
|
||||
// Draw button to open repository link.
|
||||
let mut git_clicked = false;
|
||||
View::button(ui, BRACKETS_CURLY, Colors::white_or_black(false), || {
|
||||
View::button(ui, BOOK, Colors::white_or_black(false), || {
|
||||
git_clicked = true;
|
||||
});
|
||||
if git_clicked {
|
||||
|
||||
@@ -50,7 +50,7 @@ pub struct WalletContent {
|
||||
/// Send request creation [`Modal`] content.
|
||||
send_content: Option<SendRequestContent>,
|
||||
|
||||
/// Goblin Cash App-style surface (primary UI).
|
||||
/// Goblin payment-app-style surface (primary UI).
|
||||
goblin: crate::gui::views::goblin::GoblinWalletView,
|
||||
}
|
||||
|
||||
@@ -83,8 +83,15 @@ impl WalletContentContainer for WalletContent {
|
||||
}
|
||||
|
||||
fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
// Drawing this wallet means the app is foreground with the wallet
|
||||
// on-screen: resume on-demand node polling right away so the balance
|
||||
// goes live (the sync thread otherwise re-checks the foreground
|
||||
// signal only once per cycle). No-op unless polling was paused.
|
||||
if wallet.node_polling_paused() {
|
||||
wallet.resume_node_polling();
|
||||
}
|
||||
// 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.
|
||||
// ready, then hand the whole surface to the payment-app-style view.
|
||||
let block_nav_goblin = self.block_navigation_on_sync(wallet);
|
||||
if block_nav_goblin || wallet.get_data().is_none() {
|
||||
egui::CentralPanel::default()
|
||||
@@ -305,9 +312,17 @@ impl WalletContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if it's possible to go back at navigation stack.
|
||||
/// Check if it's possible to go back at navigation stack. Delegates to the
|
||||
/// goblin view too (overlays, settings sub-pages, non-Home tabs), so the
|
||||
/// host never falls through to the wallet chooser on back.
|
||||
pub fn can_back(&self) -> bool {
|
||||
self.goblin.overlay_active() || self.account_content.can_back()
|
||||
self.goblin.can_back() || self.account_content.can_back()
|
||||
}
|
||||
|
||||
/// Take the pending "switch wallet" request from the goblin settings, so the
|
||||
/// host can deselect this wallet (return to the chooser) without locking it.
|
||||
pub fn take_switch_request(&mut self) -> bool {
|
||||
self.goblin.take_switch_request()
|
||||
}
|
||||
|
||||
/// Navigate back on navigation stack. Returns true if not consumed.
|
||||
|
||||
@@ -324,7 +324,7 @@ impl SendRequestContent {
|
||||
if self.amount_edit.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Check address to send over Tor if enabled.
|
||||
// Validate the recipient slatepack address.
|
||||
let addr_str = self.address_edit.as_str();
|
||||
if let Ok(r) = SlatepackAddress::try_from(addr_str.trim()) {
|
||||
if let Ok(a) = amount_from_hr_string(self.amount_edit.as_str()) {
|
||||
|
||||
@@ -18,5 +18,5 @@ pub use client::*;
|
||||
mod release;
|
||||
pub use release::*;
|
||||
|
||||
mod price;
|
||||
pub use price::grin_rate;
|
||||
pub(crate) mod price;
|
||||
pub use price::{RateState, grin_rate};
|
||||
|
||||
@@ -12,29 +12,70 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! GRIN price preview, fetched over the Nym mixnet and cached per currency.
|
||||
//! GRIN price preview. Off by default (no pairing → no fetch); only once the
|
||||
//! user opts into a pairing is the rate fetched — and it goes over Tor like
|
||||
//! everything else, never the clear net.
|
||||
//!
|
||||
//! The rate is fetched LIVE, on view: the fiat subline asks for the rate only
|
||||
//! while the balance is actually on screen, and a fetch is kicked whenever the
|
||||
//! last one aged past a short freshness window ([`FRESH_SECS`]). There is no
|
||||
//! disk cache and no background timer — an idle or payment-listening wallet
|
||||
//! never polls, so it costs nothing on battery. The rate lives in memory for
|
||||
//! the freshness window so flipping between screens does not refetch, and a
|
||||
//! fresh session starts blank and fetches on the first view.
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::nym;
|
||||
use crate::AppConfig;
|
||||
use crate::tor;
|
||||
|
||||
/// Cache refresh interval (seconds).
|
||||
const REFRESH_SECS: i64 = 300;
|
||||
/// How long an in-memory rate is considered current (seconds). Viewing the
|
||||
/// balance with a rate older than this kicks a live refetch, and until a fresh
|
||||
/// rate lands the stale value is NOT painted — the line shows loading, then the
|
||||
/// new rate (or unavailable on failure). Short enough to track the market, long
|
||||
/// enough that screen flips within the window reuse the same fetch.
|
||||
const FRESH_SECS: i64 = 180;
|
||||
|
||||
/// Minimum delay between fetch attempts for a currency, so a failing fetch
|
||||
/// (e.g. the mixnet still bootstrapping) does not respawn a thread every frame.
|
||||
/// (e.g. no network) does not respawn a thread every frame.
|
||||
const RETRY_SECS: i64 = 30;
|
||||
|
||||
/// Eager-probe per-try timeout. The eager fetch on tunnel-ready doubles as the
|
||||
/// end-to-end exit probe: a healthy warm fetch is ~800ms, a dead exit hangs until
|
||||
/// timeout, so a short cap lets us fail fast and condemn a bad exit in seconds.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
|
||||
/// How many eager-probe fetch attempts before we conclude the (still-"ready")
|
||||
/// exit is blackholing HTTP and condemn it.
|
||||
const PROBE_ATTEMPTS: u32 = 3;
|
||||
|
||||
lazy_static! {
|
||||
/// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at).
|
||||
/// In-session GRIN rates per `vs_currency`: code -> (rate, fetched_at). Memory
|
||||
/// only — never persisted, so a fresh session starts empty.
|
||||
static ref RATES: RwLock<HashMap<String, (f64, i64)>> = RwLock::new(HashMap::new());
|
||||
/// Currencies with a fetch currently in flight.
|
||||
static ref FETCHING: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
|
||||
/// Last fetch attempt per currency (unix secs).
|
||||
static ref LAST_TRY: RwLock<HashMap<String, i64>> = RwLock::new(HashMap::new());
|
||||
/// Currencies whose most recent completed attempt failed (with no fresh rate),
|
||||
/// so the line can honestly say "unavailable" instead of spinning forever.
|
||||
static ref FAILED: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
|
||||
}
|
||||
|
||||
/// What the fiat line should render for a currency right now.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum RateState {
|
||||
/// A rate fetched within the freshness window — safe to paint as current.
|
||||
Fresh(f64),
|
||||
/// No current rate yet, but a fetch is in flight (or just kicked): show a
|
||||
/// subtle placeholder, not a stale number.
|
||||
Loading,
|
||||
/// The last fetch failed and nothing fresh is available: say so (or hide),
|
||||
/// never fall back to an old value.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
@@ -44,22 +85,51 @@ fn now() -> i64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get the cached GRIN rate against `vs` (e.g. "usd", "eur", "btc") if fresh,
|
||||
/// triggering a background refresh otherwise. Returns `None` until the first
|
||||
/// successful fetch for that currency.
|
||||
pub fn grin_rate(vs: &str) -> Option<f64> {
|
||||
let cached = { RATES.read().get(vs).cloned() };
|
||||
let needs_refresh = match cached {
|
||||
Some((_, ts)) => now() - ts > REFRESH_SECS,
|
||||
None => true,
|
||||
};
|
||||
if needs_refresh {
|
||||
trigger_refresh(vs.to_string());
|
||||
}
|
||||
cached.map(|(rate, _)| rate)
|
||||
/// True if a rate fetched at `fetched_at` is still current as of `now`.
|
||||
fn is_fresh(fetched_at: i64, now: i64) -> bool {
|
||||
now - fetched_at <= FRESH_SECS
|
||||
}
|
||||
|
||||
/// Spawn a background refresh over the mixnet for one currency (deduped per code).
|
||||
/// Pure state decision, factored out for testing: given the (optional) cached
|
||||
/// rate and the current fetch bookkeeping, decide what to render. A cached rate
|
||||
/// older than the freshness window is deliberately NOT returned as `Fresh`.
|
||||
fn classify(cached: Option<(f64, i64)>, now: i64, fetching: bool, failed: bool) -> RateState {
|
||||
if let Some((rate, ts)) = cached {
|
||||
if is_fresh(ts, now) {
|
||||
return RateState::Fresh(rate);
|
||||
}
|
||||
}
|
||||
// No fresh rate. A fetch in flight (or just kicked) reads as loading; a
|
||||
// recently failed attempt with nothing fresh reads as unavailable.
|
||||
if fetching {
|
||||
RateState::Loading
|
||||
} else if failed {
|
||||
RateState::Unavailable
|
||||
} else {
|
||||
RateState::Loading
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the render state of the GRIN rate against `vs` (e.g. "usd", "eur",
|
||||
/// "btc"). Called from the fiat line while the balance is on screen: if the
|
||||
/// in-session rate is missing or stale it kicks a live refetch over Tor, and it
|
||||
/// never reports a rate older than the freshness window as current.
|
||||
pub fn grin_rate(vs: &str) -> RateState {
|
||||
let cached = { RATES.read().get(vs).cloned() };
|
||||
let fresh = cached.map(|(_, ts)| is_fresh(ts, now())).unwrap_or(false);
|
||||
if !fresh {
|
||||
trigger_refresh(vs.to_string());
|
||||
}
|
||||
classify(
|
||||
cached,
|
||||
now(),
|
||||
FETCHING.read().contains(vs),
|
||||
FAILED.read().contains(vs),
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a background refresh for one currency (deduped per code). Kicked only
|
||||
/// from a view that is actually asking for the rate — never on a timer.
|
||||
fn trigger_refresh(vs: String) {
|
||||
let t = now();
|
||||
{
|
||||
@@ -81,33 +151,164 @@ fn trigger_refresh(vs: String) {
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let ok = rt.block_on(async {
|
||||
if let Some(rate) = fetch_rate(&vs).await {
|
||||
RATES.write().insert(vs.clone(), (rate, now()));
|
||||
record_rate(&vs, rate);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if ok {
|
||||
FAILED.write().remove(&vs);
|
||||
} else {
|
||||
FAILED.write().insert(vs.clone());
|
||||
}
|
||||
FETCHING.write().remove(&vs);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet.
|
||||
/// Record a freshly fetched rate into the in-memory cache (with `now()`) and
|
||||
/// clear any prior failure flag. Nothing is written to disk.
|
||||
fn record_rate(vs: &str, rate: f64) {
|
||||
RATES.write().insert(vs.to_string(), (rate, now()));
|
||||
FAILED.write().remove(vs);
|
||||
}
|
||||
|
||||
/// Kick a refresh for the current pairing's currency the moment the tunnel is
|
||||
/// ready, bypassing the [`RETRY_SECS`] gate (but keeping the [`FETCHING`] dedupe).
|
||||
/// It doubles as the end-to-end exit probe: if every attempt fails while the
|
||||
/// tunnel still reports ready, the exit is blackholing HTTP despite passing the
|
||||
/// cheap liveness probe, so we condemn it (bounded: at most one condemnation per
|
||||
/// tunnel generation) rather than let it stall the wallet for minutes. This is a
|
||||
/// one-shot per tunnel connection, not a poll loop.
|
||||
pub fn eager_refresh() {
|
||||
let vs = match AppConfig::pairing().vs_currency() {
|
||||
Some(vs) => vs.to_string(),
|
||||
// Pairing off → nothing to fetch, so no probe either (we never fetch a
|
||||
// price the user hasn't opted into). The watchdog's own signals govern.
|
||||
None => return,
|
||||
};
|
||||
{
|
||||
let mut fetching = FETCHING.write();
|
||||
if fetching.contains(&vs) {
|
||||
return;
|
||||
}
|
||||
fetching.insert(vs.clone());
|
||||
}
|
||||
LAST_TRY.write().insert(vs.clone(), now());
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let generation = tor::tunnel_generation();
|
||||
let mut ok = false;
|
||||
for attempt in 1..=PROBE_ATTEMPTS {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, fetch_rate(&vs)).await {
|
||||
Ok(Some(rate)) => {
|
||||
record_rate(&vs, rate);
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
log::warn!(
|
||||
"price: eager probe fetch {attempt}/{PROBE_ATTEMPTS} failed \
|
||||
(vs {vs}, gen {generation})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
FAILED.write().remove(&vs);
|
||||
} else {
|
||||
FAILED.write().insert(vs.clone());
|
||||
}
|
||||
// Every attempt failed AND the tunnel still claims ready on the SAME
|
||||
// generation we probed: the exit is up but blackholing our HTTP. Condemn
|
||||
// it so a fresh exit is selected in seconds, not minutes. Guarded to the
|
||||
// probed generation so a reselect that already happened is never hit.
|
||||
if !ok && tor::is_ready() && tor::tunnel_generation() == generation {
|
||||
tor::condemn_exit(generation);
|
||||
}
|
||||
});
|
||||
FETCHING.write().remove(&vs);
|
||||
}
|
||||
|
||||
/// Fetch the GRIN/`vs` rate from CoinGecko over Tor.
|
||||
async fn fetch_rate(vs: &str) -> Option<f64> {
|
||||
let url = format!(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies={}",
|
||||
vs
|
||||
);
|
||||
// CoinGecko rejects requests without a User-Agent (403). A static,
|
||||
// non-identifying UA is fine over the mixnet.
|
||||
let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())];
|
||||
let body = nym::http_request("GET", url, None, headers).await?;
|
||||
// CoinGecko rejects requests without a User-Agent (403); the Tor client sets a
|
||||
// browser-like default UA on every request, so we pass no extra headers here
|
||||
// (passing one again would send the header twice).
|
||||
let body = tor::http_request("GET", url, None, vec![]).await?;
|
||||
let parsed: Option<f64> = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|doc| doc.get("grin")?.get(vs)?.as_f64());
|
||||
if parsed.is_none() {
|
||||
log::warn!(
|
||||
"price: unexpected response from rate API (mixnet exit blocked?): {}",
|
||||
"price: unexpected response from rate API: {}",
|
||||
body.chars().take(120).collect::<String>()
|
||||
);
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn freshness_window_is_a_few_minutes() {
|
||||
// Guards the ruling: a short window in minutes, not the old 48h cache.
|
||||
assert!(
|
||||
(120..=300).contains(&FRESH_SECS),
|
||||
"FRESH_SECS = {FRESH_SECS}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fresh_at_boundary() {
|
||||
let now = 1_000_000;
|
||||
assert!(is_fresh(now, now)); // just fetched
|
||||
assert!(is_fresh(now - FRESH_SECS, now)); // exactly on the edge is still fresh
|
||||
assert!(!is_fresh(now - FRESH_SECS - 1, now)); // one second past → stale
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_fresh_rate_is_painted() {
|
||||
let now = 1_000_000;
|
||||
let cached = Some((1.23, now - 10));
|
||||
// Even mid-fetch, a fresh rate wins.
|
||||
assert_eq!(classify(cached, now, true, false), RateState::Fresh(1.23));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_stale_rate_never_painted_shows_loading_while_fetching() {
|
||||
let now = 1_000_000;
|
||||
let cached = Some((1.23, now - FRESH_SECS - 60));
|
||||
assert_eq!(classify(cached, now, true, false), RateState::Loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_stale_rate_after_failure_is_unavailable() {
|
||||
let now = 1_000_000;
|
||||
let cached = Some((1.23, now - FRESH_SECS - 60));
|
||||
// Not fetching, last attempt failed → honest "unavailable", not the old value.
|
||||
assert_eq!(classify(cached, now, false, true), RateState::Unavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_missing_rate_loads_then_reports_failure() {
|
||||
let now = 1_000_000;
|
||||
// First view: nothing cached, fetch just kicked.
|
||||
assert_eq!(classify(None, now, true, false), RateState::Loading);
|
||||
// Fetch finished and failed, nothing fresh → unavailable.
|
||||
assert_eq!(classify(None, now, false, true), RateState::Unavailable);
|
||||
// Freshly triggered, not yet marked fetching or failed → still loading.
|
||||
assert_eq!(classify(None, now, false, false), RateState::Loading);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,42 +47,43 @@ const ARM_ARCH: &str = "arm";
|
||||
const ARCH: &'static str = ARM_ARCH;
|
||||
|
||||
/// Base endpoint to download the release.
|
||||
const BASE_DOWNLOAD_URL: &'static str = "https://code.gri.mw/GUI/grim/releases/download/";
|
||||
const BASE_DOWNLOAD_URL: &'static str = "https://github.com/2ro/goblin/releases/download/";
|
||||
|
||||
impl ReleaseInfo {
|
||||
/// Get version number.
|
||||
/// Release version (the build tag, e.g. "build71").
|
||||
pub fn version(&self) -> String {
|
||||
self.tag_name.replace("v", "")
|
||||
self.tag_name.clone()
|
||||
}
|
||||
|
||||
/// Get artifact release name based on current platform.
|
||||
/// Get artifact release name based on current platform. Matches the assets
|
||||
/// attached to Goblin's GitHub releases; platforms Goblin doesn't ship
|
||||
/// (linux-arm, macOS, windows-arm) return None.
|
||||
fn name(&self) -> Option<String> {
|
||||
let os = OperatingSystem::from_target_os();
|
||||
match os {
|
||||
OperatingSystem::Unknown => None,
|
||||
OperatingSystem::Android => {
|
||||
let name = if ARCH == ARM_ARCH {
|
||||
format!("grim-{}-android.apk", self.tag_name)
|
||||
format!("goblin-{}-android-arm.apk", self.tag_name)
|
||||
} else {
|
||||
format!("grim-{}-android-x86_64.apk", self.tag_name)
|
||||
format!("goblin-{}-android-x86_64.apk", self.tag_name)
|
||||
};
|
||||
Some(name)
|
||||
}
|
||||
OperatingSystem::IOS => None,
|
||||
OperatingSystem::Nix => {
|
||||
let name = if ARCH == ARM_ARCH {
|
||||
format!("grim-{}-linux-arm.AppImage", self.tag_name)
|
||||
if ARCH == ARM_ARCH {
|
||||
None
|
||||
} else {
|
||||
format!("grim-{}-linux-x86_64.AppImage", self.tag_name)
|
||||
};
|
||||
Some(name)
|
||||
Some(format!("goblin-{}-linux-x86_64.tar.gz", self.tag_name))
|
||||
}
|
||||
}
|
||||
OperatingSystem::Mac => Some(format!("grim-{}-macos-universal.zip", self.tag_name)),
|
||||
OperatingSystem::Mac => None,
|
||||
OperatingSystem::Windows => {
|
||||
if ARCH == ARM_ARCH {
|
||||
None
|
||||
} else {
|
||||
Some(format!("grim-{}-win-x86_64.msi", self.tag_name))
|
||||
Some(format!("goblin-{}-win-x86_64.zip", self.tag_name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,46 +120,30 @@ impl ReleaseInfo {
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if release is update.
|
||||
/// Whether this release is newer than the running build. Goblin versions by
|
||||
/// build number ("buildNN" tags) rather than semver, so compare the numbers.
|
||||
pub fn is_update(&self) -> bool {
|
||||
let cur = crate::VERSION;
|
||||
let ver = self.version();
|
||||
if cur == ver {
|
||||
return false;
|
||||
}
|
||||
let cur_numbers: Vec<i32> = cur
|
||||
.split(".")
|
||||
.filter_map(|s| s.parse::<i32>().ok())
|
||||
.collect();
|
||||
let ver_numbers: Vec<i32> = ver
|
||||
.split(".")
|
||||
.filter_map(|s| s.parse::<i32>().ok())
|
||||
.collect();
|
||||
if cur_numbers.len() != ver_numbers.len() {
|
||||
return true;
|
||||
}
|
||||
for (i, num) in ver_numbers.iter().enumerate() {
|
||||
if num > &cur_numbers.get(i).unwrap() {
|
||||
if i == 0 {
|
||||
return true;
|
||||
} else if i == 1 && cur_numbers.get(0).unwrap() == ver_numbers.get(0).unwrap() {
|
||||
return true;
|
||||
} else if i == 2 && cur_numbers.get(1).unwrap() == ver_numbers.get(1).unwrap() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
let cur: u64 = crate::BUILD.trim().parse().unwrap_or(0);
|
||||
let rel: u64 = self
|
||||
.tag_name
|
||||
.trim()
|
||||
.trim_start_matches("build")
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
rel > cur
|
||||
}
|
||||
}
|
||||
|
||||
/// API endpoint to check last release.
|
||||
const REQUEST_URL: &'static str = "https://code.gri.mw/api/v1/repos/gui/grim/releases/latest";
|
||||
/// API endpoint to check last release (Goblin's own GitHub releases).
|
||||
const REQUEST_URL: &'static str = "https://api.github.com/repos/2ro/goblin/releases/latest";
|
||||
|
||||
pub async fn retrieve_release() -> Result<ReleaseInfo, String> {
|
||||
let req = hyper::Request::builder()
|
||||
.method(hyper::Method::GET)
|
||||
.uri(REQUEST_URL)
|
||||
// GitHub's API rejects requests without a User-Agent.
|
||||
.header("User-Agent", "goblin-wallet")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.body(Empty::<Bytes>::new())
|
||||
.unwrap();
|
||||
if let Ok(resp) = HttpClient::send(req).await {
|
||||
|
||||
@@ -38,8 +38,14 @@ mod http;
|
||||
pub mod logger;
|
||||
mod node;
|
||||
pub mod nostr;
|
||||
mod nym;
|
||||
/// The old Nym-mixnet transport, DORMANT since the Tor swap. Retained on disk but
|
||||
/// only compiled with `--features nym` (its nym-sdk deps link a different
|
||||
/// libsqlite3-sys than arti and cannot coexist with Tor in one binary). Deletion
|
||||
/// is a later phase.
|
||||
#[cfg(feature = "nym")]
|
||||
pub mod nym;
|
||||
mod settings;
|
||||
pub mod tor;
|
||||
mod wallet;
|
||||
|
||||
/// Upstream GRIM version the fork is based on (third-party credit).
|
||||
@@ -117,16 +123,25 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
|
||||
// would panic on the first TLS handshake. nym uses its own explicit provider,
|
||||
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
// Pre-warm the embedded Tor client FIRST, before i18n/node setup, so the Tor
|
||||
// bootstrap (the long pole on cold start) overlaps everything else and
|
||||
// price/NIP-05/nostr are ready at first use. All of Goblin's relay + HTTP
|
||||
// traffic egresses through Tor; the Grin node stays on the clear internet
|
||||
// exactly as before (its lazy warm-on-activity polling is untouched).
|
||||
tor::warm_up();
|
||||
// Setup translations.
|
||||
setup_i18n();
|
||||
// Start integrated node if needed.
|
||||
if AppConfig::autostart_node() {
|
||||
Node::start();
|
||||
}
|
||||
// Pre-warm the in-process Nym mixnet client so price/NIP-05/nostr are ready at
|
||||
// first use. All of Goblin's outbound traffic egresses through it; nothing
|
||||
// clearnet.
|
||||
nym::warm_up();
|
||||
// macOS delivers `goblin:` link clicks as a kAEGetURL Apple Event, not on argv
|
||||
// and not through any path winit/eframe surface. Install a handler for it here,
|
||||
// BEFORE the event loop starts, so both a cold launch (event queued at start-up)
|
||||
// and a warm click (app already running) route the URL into the same argv entry
|
||||
// (`on_data`). No-op / not compiled on every other platform.
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_deeplink::install();
|
||||
// Launch graphical interface.
|
||||
eframe::run_native("Goblin", options, app_creator)
|
||||
}
|
||||
@@ -335,18 +350,21 @@ fn setup_i18n() {
|
||||
}
|
||||
} else {
|
||||
let locale = sys_locale::get_locale().unwrap_or(String::from(AppConfig::DEFAULT_LOCALE));
|
||||
let locale_str = if locale.contains("-") {
|
||||
locale
|
||||
.split("-")
|
||||
.next()
|
||||
.unwrap_or(AppConfig::DEFAULT_LOCALE)
|
||||
} else {
|
||||
locale.as_str()
|
||||
};
|
||||
|
||||
// Set best possible locale.
|
||||
if rust_i18n::available_locales!().contains(&locale_str) {
|
||||
rust_i18n::set_locale(locale_str);
|
||||
// sys_locale may hand back either `zh-CN` or `zh_CN`; normalize the
|
||||
// separator so a region-specific locale can match its file name.
|
||||
let normalized = locale.replace('_', "-");
|
||||
let available = rust_i18n::available_locales!();
|
||||
// Prefer an exact region match (e.g. `zh-CN`, the only CJK locale and one
|
||||
// the bare-subtag fallback could never reach), then the language subtag
|
||||
// (e.g. `de` from `de-DE`), else the default.
|
||||
let primary = normalized
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or(AppConfig::DEFAULT_LOCALE);
|
||||
if available.contains(&normalized.as_str()) {
|
||||
rust_i18n::set_locale(normalized.as_str());
|
||||
} else if available.contains(&primary) {
|
||||
rust_i18n::set_locale(primary);
|
||||
} else {
|
||||
rust_i18n::set_locale(AppConfig::DEFAULT_LOCALE);
|
||||
}
|
||||
@@ -375,9 +393,184 @@ pub fn on_data(data: String) {
|
||||
*w_data = Some(data);
|
||||
}
|
||||
|
||||
/// macOS-only bridge that turns a `goblin:` URL click into an [`on_data`] call.
|
||||
///
|
||||
/// On Linux/Windows the OS hands the URL to the app on argv (cold) or through the
|
||||
/// single-instance socket (warm); on Android it arrives as an Intent. macOS uses
|
||||
/// neither: it dispatches scheme clicks as a Carbon/Apple Event (`kAEGetURL`) that
|
||||
/// winit + eframe never surface, so the click would otherwise vanish. We install a
|
||||
/// handler for that event straight on the shared `NSAppleEventManager`, extract the
|
||||
/// URL string, and feed it to [`on_data`] — the exact same entry point argv uses,
|
||||
/// so the Goblin surface's per-frame router lands the pay URI on the prefilled
|
||||
/// review screen just as a scanned QR or a Linux argv link does.
|
||||
///
|
||||
/// This talks to the Objective-C runtime through the classic `objc` crate, which is
|
||||
/// already in the macOS build graph (nokhwa/cocoa/metal/wgpu all pull it), so it
|
||||
/// adds no new dependency and only a few KB of handler code. The Apple Event route
|
||||
/// deliberately avoids the `NSApplicationDelegate` that winit owns — registering our
|
||||
/// own `kAEGetURL` handler neither subclasses nor swizzles winit's delegate.
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_deeplink {
|
||||
use objc::declare::ClassDecl;
|
||||
use objc::runtime::{Object, Sel};
|
||||
use objc::{class, msg_send, sel, sel_impl};
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::sync::Once;
|
||||
|
||||
// Four-char codes (OSType) for the URL-open Apple Event.
|
||||
const K_INTERNET_EVENT_CLASS: u32 = 0x4755_524c; // 'GURL'
|
||||
const K_AE_GET_URL: u32 = 0x4755_524c; // 'GURL'
|
||||
const KEY_DIRECT_OBJECT: u32 = 0x2d2d_2d2d; // '----'
|
||||
|
||||
/// `- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event
|
||||
/// withReplyEvent:(NSAppleEventDescriptor *)reply`.
|
||||
extern "C" fn handle_get_url(
|
||||
_this: &Object,
|
||||
_cmd: Sel,
|
||||
event: *mut Object,
|
||||
_reply: *mut Object,
|
||||
) {
|
||||
if event.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
// [[event paramDescriptorForKeyword:keyDirectObject] stringValue] -> NSString*.
|
||||
let desc: *mut Object = msg_send![event, paramDescriptorForKeyword: KEY_DIRECT_OBJECT];
|
||||
if desc.is_null() {
|
||||
return;
|
||||
}
|
||||
let url: *mut Object = msg_send![desc, stringValue];
|
||||
if url.is_null() {
|
||||
return;
|
||||
}
|
||||
let utf8: *const c_char = msg_send![url, UTF8String];
|
||||
if utf8.is_null() {
|
||||
return;
|
||||
}
|
||||
let s = CStr::from_ptr(utf8).to_string_lossy().into_owned();
|
||||
if !s.is_empty() {
|
||||
crate::on_data(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the `kAEGetURL` handler on the shared `NSAppleEventManager`. Idempotent
|
||||
/// (the handler class is built once); call before the event loop starts.
|
||||
pub fn install() {
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| unsafe {
|
||||
// Build a tiny NSObject subclass carrying the handler method.
|
||||
let superclass = class!(NSObject);
|
||||
let mut decl = match ClassDecl::new("GoblinAppleEventHandler", superclass) {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
decl.add_method(
|
||||
sel!(handleGetURLEvent:withReplyEvent:),
|
||||
handle_get_url as extern "C" fn(&Object, Sel, *mut Object, *mut Object),
|
||||
);
|
||||
let cls = decl.register();
|
||||
|
||||
// One instance, intentionally leaked: it must outlive every event for the
|
||||
// whole process lifetime, and the app never unregisters.
|
||||
let handler: *mut Object = msg_send![cls, new];
|
||||
let manager: *mut Object =
|
||||
msg_send![class!(NSAppleEventManager), sharedAppleEventManager];
|
||||
let _: () = msg_send![manager,
|
||||
setEventHandler: handler
|
||||
andSelector: sel!(handleGetURLEvent:withReplyEvent:)
|
||||
forEventClass: K_INTERNET_EVENT_CLASS
|
||||
andEventID: K_AE_GET_URL];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix-seconds timestamp of the most recent GUI frame. Background workers read
|
||||
/// it to tell whether the app is actually on-screen: while the app is
|
||||
/// backgrounded, eframe stops calling the per-frame draw and this stops
|
||||
/// advancing. Crate-root so both `gui` and `nostr` can reach it without coupling.
|
||||
static LAST_FRAME_AT: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
|
||||
|
||||
/// A frame older than this many seconds means the app isn't drawing — i.e. it's
|
||||
/// backgrounded/occluded. The GUI keeps a ~2s repaint heartbeat while visible, so
|
||||
/// this leaves a couple of frames of margin before declaring "not foreground".
|
||||
const FOREGROUND_STALE_SECS: i64 = 5;
|
||||
|
||||
fn now_unix_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Stamp that the GUI just drew a frame. Called once per frame from the app loop.
|
||||
pub fn mark_frame() {
|
||||
LAST_FRAME_AT.store(now_unix_secs(), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// True when the GUI drew a frame within the last few seconds — i.e. the app is
|
||||
/// foreground and visible. While backgrounded (no frames), returns false, so
|
||||
/// periodic background work (the @name re-verify sweep) can pause and catch up
|
||||
/// on resume instead of burning mixnet round-trips while nobody's looking.
|
||||
pub fn app_foreground() -> bool {
|
||||
let last = LAST_FRAME_AT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
last != 0 && now_unix_secs() - last <= FOREGROUND_STALE_SECS
|
||||
}
|
||||
|
||||
/// Fire the platform "payment received" notification with the payer's display
|
||||
/// name and human-readable amount. Android shows a one-shot system
|
||||
/// notification (`BackgroundService.notifyPaymentReceived`, id=2, separate
|
||||
/// from the persistent sync notification); other platforms are a no-op.
|
||||
/// Crate-root so the nostr service can reach it without holding a platform
|
||||
/// reference.
|
||||
pub fn notify_payment_received(name: &str, amount: &str) {
|
||||
#[cfg(target_os = "android")]
|
||||
gui::platform::notify_payment_received(name, amount);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (name, amount);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the platform "payment requested" notification with the requester's
|
||||
/// display name and human-readable amount, for an incoming payment request
|
||||
/// (someone asking us to pay them). Android shows a one-shot system
|
||||
/// notification (`BackgroundService.notifyPaymentRequested`, id=3, separate from
|
||||
/// both the persistent sync notification id=1 and the received-payment one
|
||||
/// id=2); other platforms are a no-op. Crate-root so the nostr service can reach
|
||||
/// it without holding a platform reference. Mirrors [`notify_payment_received`].
|
||||
pub fn notify_payment_requested(name: &str, amount: &str) {
|
||||
#[cfg(target_os = "android")]
|
||||
gui::platform::notify_payment_requested(name, amount);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (name, amount);
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// Data provided from deeplink or opened file.
|
||||
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
/// A pending `goblin:` / `nostr:` payment deep link, waiting for the Goblin
|
||||
/// wallet surface to open its send-review flow. Separate from
|
||||
/// [`INCOMING_DATA`] (slatepack messages / opened files): a payment link is
|
||||
/// routed here so it lands on the prefilled review screen rather than the
|
||||
/// slatepack message handler. Consumed by the Goblin view once a wallet is
|
||||
/// open and showing.
|
||||
static ref PENDING_PAY_URI: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
/// Stash a payment deep link for the Goblin surface to open (see
|
||||
/// [`take_pending_pay_uri`]). The most recent link wins.
|
||||
pub fn set_pending_pay_uri(uri: String) {
|
||||
*PENDING_PAY_URI.write() = Some(uri);
|
||||
}
|
||||
|
||||
/// Take (and clear) a pending payment deep link, if any. The Goblin wallet view
|
||||
/// polls this each frame and opens a prefilled send-review flow for it.
|
||||
pub fn take_pending_pay_uri() -> Option<String> {
|
||||
PENDING_PAY_URI.write().take()
|
||||
}
|
||||
|
||||
/// Callback from Java code with passed data.
|
||||
|
||||
@@ -114,7 +114,7 @@ pub fn init_logger() {
|
||||
/// Get information about application build.
|
||||
fn build_info() -> String {
|
||||
format!(
|
||||
"This is Grim version {}, built for {} by {}.",
|
||||
"This is Goblin version {}, built for {} by {}.",
|
||||
built_info::PKG_VERSION,
|
||||
built_info::TARGET,
|
||||
built_info::RUSTC_VERSION,
|
||||
|
||||
@@ -721,7 +721,15 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncStatusText(
|
||||
_class: jni::objects::JObject,
|
||||
_activity: jni::objects::JObject,
|
||||
) -> jni::sys::jstring {
|
||||
let status_text = Node::get_sync_status_text();
|
||||
// The keep-alive notification must reflect the real connection: on the
|
||||
// external-node default the integrated node is deliberately off, so "Node
|
||||
// is down" is wrong — the service's actual background job is the
|
||||
// Nostr-over-Nym payment listen.
|
||||
let status_text = if Node::is_running() || Node::is_starting() {
|
||||
Node::get_sync_status_text()
|
||||
} else {
|
||||
t!("goblin.home.listening").into()
|
||||
};
|
||||
let j_text = _env.new_string(status_text);
|
||||
return j_text.unwrap().into_raw();
|
||||
}
|
||||
@@ -736,7 +744,14 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncTitle(
|
||||
_class: jni::objects::JObject,
|
||||
_activity: jni::objects::JObject,
|
||||
) -> jni::sys::jstring {
|
||||
let j_text = _env.new_string(t!("network.node"));
|
||||
// Match the status text: only title the notification "Node" when the
|
||||
// integrated node is actually in use.
|
||||
let title = if Node::is_running() || Node::is_starting() {
|
||||
t!("network.node").to_string()
|
||||
} else {
|
||||
"Goblin".to_string()
|
||||
};
|
||||
let j_text = _env.new_string(title);
|
||||
return j_text.unwrap().into_raw();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,77 +12,13 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Client-side avatar handling: local preprocessing of a picked picture
|
||||
//! (mirrors the server pipeline so uploads over the mixnet stay small and previews
|
||||
//! are instant — the server still re-validates everything), plus a small
|
||||
//! disk cache of fetched avatars keyed by username.
|
||||
//! Client-side avatar handling: a small disk cache of fetched avatars keyed
|
||||
//! by username.
|
||||
|
||||
use image::codecs::png::PngEncoder;
|
||||
use image::metadata::Orientation;
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Output dimensions (square), matching the server.
|
||||
pub const SIZE: u32 = 256;
|
||||
/// Raw picked files larger than this are rejected before decoding.
|
||||
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Identify the image format from magic bytes alone (PNG/JPEG/WebP).
|
||||
fn sniff(raw: &[u8]) -> Option<ImageFormat> {
|
||||
if raw.len() >= 8 && raw.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
return Some(ImageFormat::Png);
|
||||
}
|
||||
if raw.len() >= 3 && raw.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
return Some(ImageFormat::Jpeg);
|
||||
}
|
||||
if raw.len() >= 12 && &raw[0..4] == b"RIFF" && &raw[8..12] == b"WEBP" {
|
||||
return Some(ImageFormat::WebP);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a picked picture file and normalize it to the canonical 256×256
|
||||
/// PNG (EXIF orientation applied, every byte of metadata destroyed).
|
||||
pub fn process_avatar_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
let meta = std::fs::metadata(path).map_err(|_| "Couldn't read that file".to_string())?;
|
||||
if meta.len() > MAX_FILE_BYTES {
|
||||
return Err("That picture is too large (10 MB max)".to_string());
|
||||
}
|
||||
let raw = std::fs::read(path).map_err(|_| "Couldn't read that file".to_string())?;
|
||||
process_avatar_bytes(&raw)
|
||||
}
|
||||
|
||||
/// Normalize raw image bytes to the canonical avatar PNG.
|
||||
pub fn process_avatar_bytes(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let err = || "That file doesn't look like a usable picture".to_string();
|
||||
let format = sniff(raw).ok_or_else(err)?;
|
||||
let mut reader = ImageReader::with_format(Cursor::new(raw), format);
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(8192);
|
||||
limits.max_image_height = Some(8192);
|
||||
limits.max_alloc = Some(128 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let mut decoder = reader.into_decoder().map_err(|_| err())?;
|
||||
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
|
||||
let mut img = DynamicImage::from_decoder(decoder).map_err(|_| err())?;
|
||||
img.apply_orientation(orientation);
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 {
|
||||
return Err(err());
|
||||
}
|
||||
let side = w.min(h);
|
||||
let img = img.crop_imm((w - side) / 2, (h - side) / 2, side, side);
|
||||
let img = img.resize_exact(SIZE, SIZE, image::imageops::FilterType::Lanczos3);
|
||||
let rgba = img.to_rgba8();
|
||||
let mut out = Vec::new();
|
||||
rgba.write_with_encoder(PngEncoder::new(&mut out))
|
||||
.map_err(|_| err())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One cached profile probe.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct CacheEntry {
|
||||
@@ -196,33 +132,6 @@ impl AvatarCache {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::RgbaImage;
|
||||
|
||||
fn png_bytes(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = RgbaImage::from_fn(w, h, |x, y| {
|
||||
image::Rgba([(x % 256) as u8, (y % 256) as u8, 7, 255])
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img)
|
||||
.write_with_encoder(PngEncoder::new(&mut out))
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processes_to_canonical_png() {
|
||||
let out = process_avatar_bytes(&png_bytes(500, 300)).unwrap();
|
||||
assert!(out.starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
let img = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (SIZE, SIZE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_images() {
|
||||
assert!(process_avatar_bytes(b"<svg onload=alert(1)></svg>").is_err());
|
||||
assert!(process_avatar_bytes(b"GIF89a....").is_err());
|
||||
assert!(process_avatar_bytes(&[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_round_trip_and_remove() {
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::nostr::relays::{DEFAULT_NIP05_SERVER, DEFAULT_RELAYS};
|
||||
/// Policy for accepting incoming payments (Standard1 slates).
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum AcceptPolicy {
|
||||
/// Accept payments from anyone automatically (default, Cash App feel).
|
||||
/// Accept payments from anyone automatically (default, instant-pay feel).
|
||||
Everyone,
|
||||
/// Auto-accept contacts, surface unknown senders for approval.
|
||||
Contacts,
|
||||
@@ -32,7 +32,7 @@ pub enum AcceptPolicy {
|
||||
}
|
||||
|
||||
/// Per-wallet nostr configuration.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct NostrConfig {
|
||||
/// Whether the nostr subsystem runs for this wallet.
|
||||
enabled: Option<bool>,
|
||||
@@ -45,6 +45,10 @@ pub struct NostrConfig {
|
||||
/// Seconds after which a still-pending transaction is auto-canceled/expired.
|
||||
/// Default 24h; lower it (e.g. 60) in nostr.toml to test the expiry flow.
|
||||
expiry_secs: Option<i64>,
|
||||
/// Seconds before the manual "Cancel payment" button appears on a still-
|
||||
/// pending send (one that never reached a relay shows it immediately).
|
||||
/// Default 10 min; lower it in nostr.toml to test the cancel flow.
|
||||
cancel_grace_secs: Option<i64>,
|
||||
/// Whether incoming payment requests (Invoice1) are accepted. Opt-out: on
|
||||
/// by default. When off, incoming requests are dropped and the preference is
|
||||
/// advertised in our kind-0 profile so requesters see it before sending.
|
||||
@@ -55,20 +59,6 @@ pub struct NostrConfig {
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for NostrConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: None,
|
||||
relays: None,
|
||||
accept_from: None,
|
||||
nip05_server: None,
|
||||
expiry_secs: None,
|
||||
allow_incoming_requests: None,
|
||||
path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrConfig {
|
||||
/// Nostr configuration file name inside the wallet directory.
|
||||
pub const FILE_NAME: &'static str = "nostr.toml";
|
||||
@@ -99,12 +89,16 @@ impl NostrConfig {
|
||||
}
|
||||
|
||||
pub fn relays(&self) -> Vec<String> {
|
||||
self.relays
|
||||
.clone()
|
||||
.filter(|r| !r.is_empty())
|
||||
self.relays_override()
|
||||
.unwrap_or_else(|| DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
/// The relay list explicitly set by the user in nostr.toml, if any. An
|
||||
/// override disables the per-identity advertised-set selection entirely.
|
||||
pub fn relays_override(&self) -> Option<Vec<String>> {
|
||||
self.relays.clone().filter(|r| !r.is_empty())
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, relays: Vec<String>) {
|
||||
self.relays = Some(relays);
|
||||
self.save();
|
||||
@@ -125,11 +119,42 @@ impl NostrConfig {
|
||||
.unwrap_or_else(|| DEFAULT_NIP05_SERVER.to_string())
|
||||
}
|
||||
|
||||
/// The name-authority HOST derived from the configured server URL (e.g.
|
||||
/// `goblin.st`). This is "home": bare names (`alice`) resolve here and own/
|
||||
/// home-domain names display without their domain. Federation: a different
|
||||
/// authority makes `alice` mean `alice@thatdomain`, while a full
|
||||
/// `bob@goblin.st` always resolves against goblin.st.
|
||||
pub fn home_domain(&self) -> String {
|
||||
let server = self.nip05_server();
|
||||
server
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Set the name-authority server (e.g. `https://other.example`). Pass an
|
||||
/// empty string to reset to the default (goblin.st).
|
||||
pub fn set_nip05_server(&mut self, server: Option<String>) {
|
||||
self.nip05_server = server.filter(|s| !s.trim().is_empty());
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// Seconds after which a still-pending transaction is auto-canceled/expired.
|
||||
pub fn expiry_secs(&self) -> i64 {
|
||||
self.expiry_secs.unwrap_or(24 * 60 * 60)
|
||||
}
|
||||
|
||||
/// Seconds before the manual cancel button appears on a pending send.
|
||||
pub fn cancel_grace_secs(&self) -> i64 {
|
||||
self.cancel_grace_secs.unwrap_or(600)
|
||||
}
|
||||
|
||||
pub fn allow_incoming_requests(&self) -> bool {
|
||||
self.allow_incoming_requests.unwrap_or(true)
|
||||
}
|
||||
|
||||
@@ -12,23 +12,24 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Per-wallet nostr identity: NIP-06 derived from the wallet mnemonic by
|
||||
//! default (one seed restores money AND identity) or imported from an nsec.
|
||||
//! Per-wallet nostr identity: a random standalone nsec (or an imported one),
|
||||
//! deliberately independent of the wallet seed — the seed proves nothing about
|
||||
//! the identity and cannot resurrect it; the nsec is its own backup.
|
||||
//! Stored at rest as NIP-49 ncryptsec encrypted with the wallet password.
|
||||
|
||||
use nostr_sdk::nips::nip44;
|
||||
use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity};
|
||||
use nostr_sdk::prelude::FromMnemonic;
|
||||
use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Where the keys came from.
|
||||
/// Where the keys came from. The legacy NIP-06 `Derived` source is gone: a
|
||||
/// pre-Build-8 `"source":"Derived"` file no longer parses, so `load()` returns
|
||||
/// `None` and wallet init writes a fresh random identity (the wanted behavior;
|
||||
/// binding a messaging identity to the money seed was the dangerous design).
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum IdentitySource {
|
||||
/// NIP-06 derivation from the wallet BIP-39 mnemonic (legacy: binds the
|
||||
/// identity to the seed forever; superseded by `Random`).
|
||||
Derived,
|
||||
/// Imported nsec.
|
||||
Imported,
|
||||
/// Freshly generated random key, independent of the wallet seed: the
|
||||
@@ -41,8 +42,6 @@ pub enum IdentitySource {
|
||||
pub struct NostrIdentity {
|
||||
pub ver: u8,
|
||||
pub source: IdentitySource,
|
||||
/// NIP-06 account index used for derivation.
|
||||
pub derivation_account: u32,
|
||||
/// NIP-49 encrypted secret key (bech32 ncryptsec).
|
||||
pub ncryptsec: String,
|
||||
/// Public key, bech32 npub (plaintext so the UI can render pre-unlock).
|
||||
@@ -54,6 +53,12 @@ pub struct NostrIdentity {
|
||||
/// Previous npubs from key rotations (newest last), for reference.
|
||||
#[serde(default)]
|
||||
pub prev_npubs: Vec<String>,
|
||||
/// Advertised DM relays (kind 10050): the Goblin relay plus 1-2 pool
|
||||
/// relays picked once for this identity and kept sticky — no timer
|
||||
/// rotation, since 10050 churn breaks payers' cached routing. Empty until
|
||||
/// the first service start selects them.
|
||||
#[serde(default)]
|
||||
pub dm_relays: Vec<String>,
|
||||
}
|
||||
|
||||
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
|
||||
@@ -141,24 +146,6 @@ impl NostrIdentity {
|
||||
let _ = fs::remove_file(Self::path(nostr_dir));
|
||||
}
|
||||
|
||||
/// Derive keys from a BIP-39 mnemonic phrase via NIP-06.
|
||||
pub fn derive_keys(mnemonic: &str, account: u32) -> Result<Keys, IdentityError> {
|
||||
Keys::from_mnemonic_with_account(mnemonic, None, Some(account))
|
||||
.map_err(|e| IdentityError::Key(format!("{e}")))
|
||||
}
|
||||
|
||||
/// Create a derived identity from the wallet mnemonic, encrypting the
|
||||
/// secret key with the wallet password.
|
||||
pub fn create_derived(
|
||||
mnemonic: &str,
|
||||
password: &str,
|
||||
account: u32,
|
||||
) -> Result<(NostrIdentity, Keys), IdentityError> {
|
||||
let keys = Self::derive_keys(mnemonic, account)?;
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Derived, account)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
|
||||
/// Build an identity from already-unlocked keys under a (possibly
|
||||
/// different) password — used when importing a backup that was exported
|
||||
/// under another wallet's password.
|
||||
@@ -166,15 +153,14 @@ impl NostrIdentity {
|
||||
keys: &Keys,
|
||||
password: &str,
|
||||
source: IdentitySource,
|
||||
account: u32,
|
||||
) -> Result<NostrIdentity, IdentityError> {
|
||||
Self::from_keys(keys, password, source, account)
|
||||
Self::from_keys(keys, password, source)
|
||||
}
|
||||
|
||||
/// Create a brand-new random identity, independent of the wallet seed.
|
||||
pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> {
|
||||
let keys = Keys::generate();
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Random, 0)?;
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Random)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
|
||||
@@ -186,7 +172,7 @@ impl NostrIdentity {
|
||||
let secret = SecretKey::parse(nsec.trim())
|
||||
.map_err(|e| IdentityError::Key(format!("invalid nsec: {e}")))?;
|
||||
let keys = Keys::new(secret);
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Imported, 0)?;
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Imported)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
|
||||
@@ -194,7 +180,6 @@ impl NostrIdentity {
|
||||
keys: &Keys,
|
||||
password: &str,
|
||||
source: IdentitySource,
|
||||
account: u32,
|
||||
) -> Result<NostrIdentity, IdentityError> {
|
||||
let encrypted = EncryptedSecretKey::new(
|
||||
keys.secret_key(),
|
||||
@@ -213,12 +198,12 @@ impl NostrIdentity {
|
||||
Ok(NostrIdentity {
|
||||
ver: 1,
|
||||
source,
|
||||
derivation_account: account,
|
||||
ncryptsec,
|
||||
npub,
|
||||
nip05: None,
|
||||
anonymous: true,
|
||||
prev_npubs: Vec::new(),
|
||||
dm_relays: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,6 +228,67 @@ impl NostrIdentity {
|
||||
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A single, fully-encrypted, portable backup of this identity (the contents
|
||||
/// of a `GOBLIN-*.backup` file). Two sealed layers, no plaintext: the secret
|
||||
/// key is the password-protected NIP-49 ncryptsec, and the rest of the
|
||||
/// identity (username, history, source) is NIP-44-sealed to our own key. An
|
||||
/// outside party sees only ciphertext — no npub, no name. Any Goblin wallet
|
||||
/// reopens it with the backup's password. `keys` must be this identity's
|
||||
/// unlocked keys (the caller unlocks with the password first).
|
||||
pub fn to_encrypted_backup(&self, keys: &Keys) -> Result<String, IdentityError> {
|
||||
let json = serde_json::to_string(self)?;
|
||||
let sealed = nip44::encrypt(
|
||||
keys.secret_key(),
|
||||
&keys.public_key(),
|
||||
json,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.map_err(|e| IdentityError::Key(format!("seal failed: {e}")))?;
|
||||
let envelope = serde_json::json!({
|
||||
"goblin_backup": 1,
|
||||
"k": self.ncryptsec,
|
||||
"d": sealed,
|
||||
});
|
||||
serde_json::to_string(&envelope).map_err(IdentityError::from)
|
||||
}
|
||||
|
||||
/// True if `s` is a Goblin encrypted-backup envelope (vs a bare nsec or the
|
||||
/// legacy plaintext identity JSON).
|
||||
pub fn is_encrypted_backup(s: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(s.trim())
|
||||
.ok()
|
||||
.and_then(|v| v.get("goblin_backup").cloned())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Open an encrypted backup with its password, returning the embedded
|
||||
/// identity and its unlocked keys.
|
||||
pub fn from_encrypted_backup(
|
||||
envelope: &str,
|
||||
password: &str,
|
||||
) -> Result<(NostrIdentity, Keys), IdentityError> {
|
||||
let v: serde_json::Value = serde_json::from_str(envelope.trim())?;
|
||||
let k = v
|
||||
.get("k")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| IdentityError::Key("backup missing key".into()))?;
|
||||
let d = v
|
||||
.get("d")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| IdentityError::Key("backup missing data".into()))?;
|
||||
// Unlock the wrapper key with the password, then open the sealed JSON.
|
||||
let enc = EncryptedSecretKey::from_bech32(k)
|
||||
.map_err(|e| IdentityError::Key(format!("invalid backup: {e}")))?;
|
||||
let secret = enc
|
||||
.decrypt(password)
|
||||
.map_err(|_| IdentityError::WrongPassword)?;
|
||||
let keys = Keys::new(secret);
|
||||
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), d)
|
||||
.map_err(|_| IdentityError::WrongPassword)?;
|
||||
let identity: NostrIdentity = serde_json::from_str(&json)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -256,18 +302,33 @@ mod tests {
|
||||
let json = serde_json::to_string(&a).unwrap();
|
||||
let parsed: NostrIdentity = serde_json::from_str(&json).unwrap();
|
||||
let keys = parsed.unlock("old-pw").unwrap();
|
||||
let b = NostrIdentity::from_unlocked_keys(
|
||||
&keys,
|
||||
"new-pw",
|
||||
parsed.source,
|
||||
parsed.derivation_account,
|
||||
)
|
||||
.unwrap();
|
||||
let b = NostrIdentity::from_unlocked_keys(&keys, "new-pw", parsed.source).unwrap();
|
||||
assert_eq!(b.npub, a.npub);
|
||||
assert!(b.unlock("new-pw").is_ok());
|
||||
assert!(b.unlock("old-pw").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_backup_roundtrips_and_is_opaque() {
|
||||
// A .backup file: sealed under one password, reopened with it. The
|
||||
// envelope must carry no plaintext npub/name, and a wrong password fails.
|
||||
let (mut a, keys) = NostrIdentity::create_random("pw-1").unwrap();
|
||||
a.nip05 = Some("jimbob@goblin.st".to_string());
|
||||
a.anonymous = false;
|
||||
let envelope = a.to_encrypted_backup(&keys).unwrap();
|
||||
assert!(NostrIdentity::is_encrypted_backup(&envelope));
|
||||
// Opaque: neither the public key nor the username leaks in the file.
|
||||
assert!(!envelope.contains(&a.npub));
|
||||
assert!(!envelope.contains("jimbob"));
|
||||
// Reopen with the password → same identity.
|
||||
let (restored, rkeys) = NostrIdentity::from_encrypted_backup(&envelope, "pw-1").unwrap();
|
||||
assert_eq!(restored.npub, a.npub);
|
||||
assert_eq!(restored.nip05.as_deref(), Some("jimbob@goblin.st"));
|
||||
assert_eq!(rkeys.public_key(), keys.public_key());
|
||||
// Wrong password can't open it.
|
||||
assert!(NostrIdentity::from_encrypted_backup(&envelope, "wrong").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_identities_are_unlinked_and_unlock() {
|
||||
let (a, ka) = NostrIdentity::create_random("pw-1").unwrap();
|
||||
@@ -281,21 +342,10 @@ mod tests {
|
||||
assert!(a.unlock("wrong").is_err());
|
||||
}
|
||||
|
||||
// NIP-06 test vector: this mnemonic must derive this npub (account 0).
|
||||
const NIP06_MNEMONIC: &str =
|
||||
"leader monkey parrot ring guide accident before fence cannon height naive bean";
|
||||
const NIP06_NPUB: &str = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu";
|
||||
|
||||
#[test]
|
||||
fn nip06_derivation_vector() {
|
||||
let keys = NostrIdentity::derive_keys(NIP06_MNEMONIC, 0).unwrap();
|
||||
assert_eq!(keys.public_key().to_bech32().unwrap(), NIP06_NPUB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_unlock_roundtrip() {
|
||||
let (identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "hunter2", 0).unwrap();
|
||||
assert_eq!(identity.source, IdentitySource::Derived);
|
||||
let (identity, keys) = NostrIdentity::create_random("hunter2").unwrap();
|
||||
assert_eq!(identity.source, IdentitySource::Random);
|
||||
assert!(identity.anonymous);
|
||||
let unlocked = identity.unlock("hunter2").unwrap();
|
||||
assert_eq!(unlocked.public_key(), keys.public_key());
|
||||
@@ -318,7 +368,7 @@ mod tests {
|
||||
fn identity_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("goblin-id-test-{}", std::process::id()));
|
||||
let (identity, _) = NostrIdentity::create_derived(NIP06_MNEMONIC, "pw", 0).unwrap();
|
||||
let (identity, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
identity.save(&dir).unwrap();
|
||||
let meta = std::fs::metadata(NostrIdentity::path(&dir)).unwrap();
|
||||
// The ncryptsec blob must never be group/world readable.
|
||||
@@ -332,7 +382,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reencrypt_changes_password() {
|
||||
let (mut identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "old", 0).unwrap();
|
||||
let (mut identity, keys) = NostrIdentity::create_random("old").unwrap();
|
||||
identity.reencrypt("old", "new").unwrap();
|
||||
assert!(identity.unlock("old").is_err());
|
||||
assert_eq!(
|
||||
|
||||
@@ -167,6 +167,12 @@ mod tests {
|
||||
received_rumor_id: None,
|
||||
created_at: unix_time(),
|
||||
updated_at: unix_time(),
|
||||
proof_mode: false,
|
||||
proof_order: None,
|
||||
proof_notify: None,
|
||||
proof_amount: None,
|
||||
proof_delivered: false,
|
||||
receipt_sent: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +275,25 @@ mod tests {
|
||||
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s2_on_cancelled_send_drops() {
|
||||
// Safety backstop for the cancel/reclaim race: once a manual "Cancel
|
||||
// payment" (or 24h expiry) marks the meta Cancelled, a late S2 from a
|
||||
// recipient who finally came online must be DROPPED — never re-finalized
|
||||
// onto outputs the sender already reclaimed.
|
||||
let m = meta(NostrTxDirection::Sent, NostrSendStatus::Cancelled, ALICE);
|
||||
let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true);
|
||||
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s2_on_finalized_send_drops() {
|
||||
// Idempotency: a duplicate S2 after we already finalized is dropped.
|
||||
let m = meta(NostrTxDirection::Sent, NostrSendStatus::Finalized, ALICE);
|
||||
let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true);
|
||||
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s2_finalizes_from_pre_dispatch_states() {
|
||||
// Created/SendFailed are deliberately accepted: a crash between
|
||||
|
||||
@@ -22,6 +22,7 @@ pub use types::*;
|
||||
pub mod config;
|
||||
pub use config::{AcceptPolicy, NostrConfig};
|
||||
|
||||
pub mod pool;
|
||||
pub mod relays;
|
||||
|
||||
mod store;
|
||||
@@ -33,6 +34,8 @@ pub use identity::{IdentitySource, NostrIdentity};
|
||||
pub mod protocol;
|
||||
pub use protocol::*;
|
||||
|
||||
pub mod wrapv3;
|
||||
|
||||
pub mod ingest;
|
||||
pub use ingest::*;
|
||||
|
||||
@@ -41,3 +44,5 @@ pub use client::{NostrProfile, NostrService, send_phase};
|
||||
|
||||
pub mod avatar;
|
||||
pub mod nip05;
|
||||
|
||||
pub mod payuri;
|
||||
|
||||