1
0
forked from GRIN/grim

516 Commits

Author SHA1 Message Date
2ro 89791793ed Goblin: dedupe balance sync indicator
Remove the Build 135 balance-hero "Updating…" line. During a routine
sync it duplicated the header node card's "Syncing" status, so a funded
wallet showed two updating indicators at once. Keep the established
"Balance updating…" line (zero-balance / first-sync case) and the header
status as the single sync indicators. Drop the now-orphan
goblin.home.updating key from all six locales (i18n drift green).
2026-07-04 12:10:52 -04:00
2ro 278a946980 chore: gitignore local release artifacts 2026-07-04 11:39:29 -04:00
2ro e8d71afc7e Goblin Build 135 - Tor credit, balance updating indicator, Nym sweep
- Third-party credits: replace the "Nym mixnet" row with "Tor (arti)" (0.43,
  linking the arti repo), matching the other credit rows' hardcoded style. No
  locale strings existed for it (credit labels are hardcoded, not localized).
- Balance: show a quiet "Updating…" line under the balance while the node is
  still warming, reusing the existing wallet.syncing() signal and the
  balance-updating line's muted style; it clears once the node is synced. New
  t!() key goblin.home.updating added to all six locales (i18n drift green).
- Nym sweep: Cargo.toml package description "Nym mixnet" -> "Tor". The only
  other user-facing Nym reference was the credit above. Remaining references
  are the deliberately-dormant `nym` feature/module and internal code comments,
  left as-is; README and all locale values were already Tor.
2026-07-04 06:15:44 -04:00
2ro 1f36631777 Goblin: retire Nym from the public toolchains + drop internal test harnesses
- CI (release.yml/build.yml): remove fetch-nym + AWS_LC_SYS_PREBUILT_NASM;
  the default cargo build --release is Tor/arti only now (nip44 fetch kept).
- Cargo.toml: nym-sdk is optional behind the `nym` feature so the default
  build never pulls it.
- Delete the dead Nym-era probes (xrelay_smoke, connect_timing,
  tunnel_measure) that referenced the removed Nym API and broke `cargo test`.
- Untrack the internal E2E harnesses (e2e.rs, nostr_e2e.rs) via .gitignore;
  gate `mod e2e` behind the `e2e-internal` feature so clones still compile.
- Refresh stale Nym comments in build.rs and the build scripts.
2026-07-04 05:59:56 -04:00
2ro 1e55ef5dfb Goblin Build 134 - Tor delivery fixes (drop the flapping onion)
build133 shipped the Tor transport but two delivery bugs remained; this
fixes both so payments resolve through confirmations again.

- Drop the pinned relay .onion. It was a single fragile hidden-service hop
  shared by every wallet; under load it flapped (WebSocket 1006), and when
  it dropped mid-handshake a payment stalled after the first gift-wrap --
  you'd get the incoming alert but the money never confirmed. Every relay is
  now reached over the Tor exit to its clearnet host: your IP still stays
  hidden from the relay, with no fragile onion hop to wedge. The has-onion
  gate that rejected an onion-less relay list is removed, and the pinned
  candidate pool carries no onion/exit fields.

- Tor-friendly relay set. Defaults/fallbacks had included relays that refuse
  Tor-exit connections (damus, nos.lol); wallets that fell back to them
  couldn't send at all. Defaults and the pinned pool are now
  relay.floonet.dev, relay.0xchat.com, offchain.pub.

- confirm-before-sent no longer false-fails across a relay reconnect (a
  confirmed-received wrap is treated as sent-pending instead of re-dispatched,
  which had duplicated wraps and hung the spinner); a v3 gift-wrap unwrap
  failure no longer silently drops the message.

Validated over Tor with two funded wallets on two independent mainnet nodes:
connect in seconds, send to finalize in 8s.
2026-07-04 05:33:10 -04:00
2ro 03c1770892 README: update transport description from Nym mixnet to Tor
The README still described the old Nym-mixnet transport, including a
reference to src/nym/sidecar.rs which no longer exists post-switch.
Update the intro, feature list, payment-travel diagram, and build
instructions to describe the current embedded-Tor (arti) transport:
in-process, dialed at the relay's pinned .onion, no sidecar, no sibling
checkout needed for the default build.
2026-07-04 03:51:21 -04:00
2ro 3c32474f75 docs(internal): Tor transport redesign plan + payment-feel report + forum post 2026-07-04 03:35:33 -04:00
2ro 30c0ed9a12 Goblin Build 133 - Tor transport (replaces the Nym mixnet)
The wallet's private transport moves from the Nym mixnet to embedded Tor
(arti, copied from GRIM's engine): it dials the relay's pinned .onion, so
the relay never learns your IP, while the relay + NIP-59 gift-wrap hide the
rest - content, sender, and (via a relay-side randomized release) timing.
The Grin node stays on the clear internet as before.

Why leave the mixnet: the Nym free-tier bandwidth this depended on is being
removed upstream (the grant expires at UTC midnight; the paid path requires
holding NYM tokens), so a payments wallet can't stand on it. Tor is
unmetered, embedded in-process on mobile, faster where users wait, and
lighter on the battery.

Preserved intact: the confirm-before-sent guard, relay-gated readiness, and
the lazy warm-on-activity node polling. src/nym/ is feature-gated off (arti
and nym-sdk can't share one binary); full removal is a follow-up.
2026-07-04 03:35:29 -04:00
2ro 22bf3359f5 nostr: revert the money-path split — all nostr on the mixnet (as prior builds), keep confirm-before-sent 2026-07-03 22:50:01 -04:00
2ro 53e18f06c7 nostr: money-path split — slatepacks over the mixnet exit, everything else clearnet
The wallet routed its ENTIRE relay.floonet.dev session (own identity, recipient
lookups, profile, catch-up, subscribe, publish) through the scoped Nym exit,
saturating its metered free-tier bandwidth so a payment gift-wrap arrived
minutes late or dropped — while nostr-sdk falsely reported "sent" (it returns on
the local mixnet-stream write, not a relay OK). Root cause was contention: ~97%
of the exit's relay bytes were non-payment overhead.

Split the nostr service into two clients:
- money client (Nym scoped exit): kind-1059 slatepacks + gift-wrap
  subscribe/catch-up + recipient resolution — NIP-05 name lookup (already
  mixnet via the HTTP tunnel) and the kind-10050 DM-relay lookup (moved here;
  it's private payment-target resolution, and cheap). Relays kept, not dropped.
- general client (clearnet): own identity (0/10002/10050), discovery, the fat
  kind-0 profile/avatar, general subs, and catch-up of non-1059.

Plus confirm-before-sent: a payment publish is not reported "sent" until a real
relay OK read-back confirms it — a slow/failed exit now surfaces as a retryable
error instead of silent money loss.

Runtime-verified: a normal session puts 0 bytes on the scoped exit (all
clearnet); a kind-1059 slatepack rides the exit and lands on relay.floonet.dev
(exit read-back + independent clearnet oracle). Exit non-payment overhead
dropped from ~50 KiB in / 164 KiB out per session to ~0.

Adds E2E test harnesses (wallet::e2e::funded_e2e_pay,
examples/nostr_split_measure, an exit publish repro in streamexit tests).
2026-07-03 22:30:25 -04:00
2ro c78d7b0e60 nym: fast fresh-gate probe budget — dead exits condemned in 10s, not ~32s
Measured (30 cold trials at the gateway-race commit): the worst cold-start
outliers (51s, 72s) were dominated by DEAD-EXIT condemnation cost, not the
gateway. A fresh tunnel on a blackholing exit burned probe_fresh's doubled
patient budget (2 calls x 2 rounds x 8s ~= 32s) before reselecting.

Split the liveness probe into two budgets over a shared probe_with_budget:
- probe() (established tunnels: watchdog keepalive + condemnation checks):
  UNCHANGED - 8s x 2 rounds, worst 16s, same patience as before.
- probe_fresh() (gating a just-built tunnel before publish): 5s x 2 rounds
  x 1 call = 10s worst. 5s is 4.2x the measured worst healthy probe
  (successful probes: min 465ms / median 774ms / max 1197ms across 15
  trials), so the build130 false-condemn regression stays far away.

DEAD-EXIT declaration drops from ~36s measured to ~13-17s total; the warn
now logs the isolated probe_ms. Also adds the gateway-race observability the
verification flagged (race START / WON-by-draw + ms / loser reaped-connected
vs dropped-pending / survivor-after-error), so the race is visible in
[timing] logs instead of inferred statistically.

Verify on the next emulator pass: DEAD EXIT probe_ms <= ~10s, zero false
DEAD EXIT across ~15 cold trials, established keepalive/condemn cadence
unchanged.
2026-07-03 15:55:37 -04:00
2ro ce23214d98 nostr: Connected flag tracks the fast relay-live probe, not the 30s catch-up fetch
Symptom: after 'Save & reconnect' of the relay list, the home/onboarding UI sat on 'Connecting relays…' for ~30s even though the relays had physically reconnected over the exit in ~2-4s.

Cause: on restart the service clears connected=false, then the UI flag was only restored AFTER publish_identity (serial, untimed per-event sends) AND a catch-up fetch_events_from bounded by FETCH_TIMEOUT=30s. One relay slow to EOSE pinned is_connected() false for the whole window while the connection was already usable. A separate FAST probe task already detects first-relay-Connected at 250ms poll (~2-4s) and reports relay-live to nymproc, but it did not touch the UI flag.

Fix: in that fast probe, when relays first report Connected (same point that calls report_relay_live), also set svc.connected=true. The indicator now tracks the real ~2-4s relay-up signal; publish_identity + the catch-up fetch continue in the background. Tradeoff (documented in code): a relay drop between the probe store(true) and the 2s status loop taking over wouldn't flip the flag for up to ~30s until the post-catch-up re-check re-syncs to reality — the same-order staleness as the old pessimistic gap, just optimistic; the transport watchdog still tracks real exit health independently.

Hardening: publish_identity's per-event send_event_to was untimed, so a stalled relay delayed the catch-up fetch and the kind:1059 subscription that follow it (real incoming-message latency). Each publish is now wrapped in tokio::time::timeout(SEND_TIMEOUT), mirroring dispatch_dm; on timeout it warns and continues to the next event, never aborting the sequence.

Audit: all readers of is_connected() were reviewed for the relaxed invariant (flag can now be true before the giftwrap subscription is established). gui/goblin/mod.rs and gui/goblin/onboarding.rs use it for display + repaint scheduling and to enable the claim-username button — claiming needs relays connected (which the flag now genuinely means), not the incoming kind:1059 subscription. wallet/e2e.rs uses it as a test precondition with downstream waits of 900s/2400s and relays replay stored gift wraps on subscribe, so it still converges. No reader treats is_connected() as 'safe to receive now', so no separate ui_connected flag is needed.
2026-07-03 15:39:02 -04:00
2ro e6e262009e nym: race two gateway connects on the cold-start path (bound the lottery tail)
The read-tunnel cold/auto path drew ONE random entry gateway; a dead draw blocked connect_to_mixnet() until run_tunnel's 10s cap and consecutive dead draws stacked into a 15-35s tail (measured 6/15 cold starts). With no warm gateway hint, build_tunnel now races TWO ephemeral gateway connects (identical anonymity cfg, ephemeral keys) via connect_gateway_racing and takes the first up; the loser is reaped (disconnected if connected, dropped if pending) so no gateway session leaks. Only the gateway handshake is doubled - the exit/IPR is still built ONCE on the winner. Warm reconnects (cached gateway hint) stay single-build. Money path (streamexit.rs) untouched.

Verify before release: emulator cold-start timing (~15 trials) shows the tail bounded, no session/task leak across many cold starts, warm path unaffected.
2026-07-03 13:52:26 -04:00
2ro 2478a7c08a ui: update banner shows the build number, not GRIM's frozen 0.3.6
The 'Update is available' fallback row rendered crate::VERSION (CARGO_PKG_VERSION = GRIM's 0.3.6) as the current version, e.g. '0.3.6 > build131' — a mismatched, GRIM-leaking string. Show the Goblin build number: 'build130 > build131'.
2026-07-03 13:40:22 -04:00
2ro d95efef896 linux: build_release.sh produces a correct arm AppImage (per-arch runtime)
The arm path hardcoded ARCH=x86_64 and the x86_64 type2 runtime, so a
`build_release.sh arm` run produced a broken AppImage (x86_64 runtime wrapping
an aarch64 binary). Derive ARCH and the runtime file from the target arch, and
have toolchain.sh fetch runtime-aarch64 alongside runtime-x86_64. This restores
GRIM's linux-arm AppImage release type; verified the output is an ARM aarch64
AppImage.
2026-07-03 13:22:47 -04:00
2ro 698806421f deps: nip44 -> crates.io 0.3.0 (drop the ../nip44 sibling requirement)
nip44 is now published, so depend on the registry crate instead of the local
path checkout. A fresh checkout (the Mac included) no longer needs ../nip44
cloned next to goblin - that missing sibling was the reported build failure.
Byte-identical source (0.3.0 was published from this tree); cargo check --lib
green. nym stays a path dep (private fork, not on crates.io).
2026-07-03 11:56:40 -04:00
2ro 61545b767d android: version tracks GOBLIN_BUILD, not GRIM's frozen 0.3.6
versionCode/versionName were inherited from GRIM (5 / "0.3.6") at the fork and
never rewired to Goblin's build number, so every build shipped as versionCode 5
- Android could not tell builds apart and Settings showed 0.3.6 for build 130.
Gradle now derives both from the GOBLIN_BUILD env (the same number the Rust side
stamps into crate::BUILD), with a fallback to the old values for a keyless local
gradle build.
2026-07-03 11:47:38 -04:00
2ro 87f82eb61e nym: race the exit-liveness probe across stable targets + rounds
The single-target, single-shot probe(1.1.1.1:443) was the read tunnel's
liveness gate AND its keepalive. On a slow/unlucky exit->1.1.1.1 path it
false-declared healthy tunnels DEAD, which rejected freshly-built tunnels and
reselected forever ("Connecting to Nym" for minutes or never) and condemned
working tunnels via keepalive. Now it races two anycast targets (1.1.1.1,
9.9.9.9) over two rounds, mirroring the DoT/DoH resolver racing above it: a
tunnel is DEAD only if it reaches NEITHER across both rounds. Verified over 15
cold starts: 15/15 connected, zero DEAD-EXIT, zero hangs (was the sole cause of
the never-resolves).
2026-07-03 11:15:54 -04:00
2ro b3165c3964 relays: default DM relay -> relay.floonet.dev (relay.goblin.st retiring); refresh Cargo.lock for nip44 0.3.0 2026-07-03 03:48:31 -04:00
2ro 4d1db9cb28 chore(relays): drop retiring relay.goblin.st from the pinned pool
relay.goblin.st + nrelay.us-ea.st retire 2026-07-04 04:44 UTC. The live
gist pool already dropped relay.goblin.st; this removes it from the
compiled-in PINNED_POOL fallback so first-run/offline no longer ships a
dead relay. relay.floonet.dev remains THE primary dm+discovery+exit
(money-path) relay.

- pool.rs: remove the relay.goblin.st entry (12 relays now) and repoint
  the pinned-pool tests onto relay.floonet.dev (counts 13->12, dm 11->10,
  discovery 4->3; the dm-membership and exit assertions). Synthetic
  parsing-logic fixtures that name relay.goblin.st are left untouched.
- wallet/e2e.rs: refresh the money-path E2E harness doc comment onto
  relay.floonet.dev (the RELAY_A/RELAY_B consts already point there) so
  the ignored on-chain test documents a live relay after retirement.

Validated: cargo test --lib pool (5 passed), cargo build ok.
2026-07-03 03:44:03 -04:00
2ro 138785cf67 test(payuri): cover the magick.market checkout pay-URI contract
Add round-trip tests asserting a realistic magick.market checkout URI
(nostr:<nprofile>?amount=<decimal GRIN>&memo=MM-<hex>) parses to the exact
recipient / amount / memo. This guards the magick.market <-> Goblin pay-URI
contract: magick converts its internal integer nanogrin to the decimal-GRIN
`amount` string the wallet's amount_from_hr_string accepts, and carries the
opaque invoice number as the memo. Test-only; the parser is unchanged.
2026-07-03 03:08:38 -04:00
2ro 9caa2b6809 nym: warm connect from caches + instant price + first-read probe
Cold connect gets a fast path: the last-known-good entry gateway and IPR
are persisted (choices only, keys stay ephemeral) and tried first on the
next launch - measured 4.4s to tunnel-ready vs 5.6s cold, and no dead-
random-gateway lottery. Cached hints self-clear on failure and fall back
to auto-select.

The price appears instantly: the last fetched rate (under 48h) paints on
the first frame and a live fetch fires the moment the tunnel is ready
instead of waiting for the balance screen. The eager fetch doubles as an
end-to-end probe: if the first reads all fail while the tunnel claims
ready, the exit is condemned and reselected in seconds instead of
minutes. warm_up now runs first at startup.

Money path (streamexit.rs, transport.rs) untouched; ponytail sweep
verified all fallback paths and gates green.
2026-07-02 23:24:39 -04:00
2ro b00719f2f9 ci: fetch nip44 in the push-triggered Build workflow too
release.yml got a fetch-nip44 step alongside fetch-nym (ae4306f), but the
push-triggered build.yml (Linux/Windows/MacOS Build) never did, so every
push has been failing cargo build with "failed to get nip44 as a
dependency ... No such file or directory" once nip44 became a path dep.
2026-07-02 23:01:38 -04:00
2ro c83771bbf8 gui: remove avatar name ring
Names never affect the avatar anymore: claimed and anonymous identities
render identically (picture or pubkey-seeded gradient). Drops
gradient_avatar_ringed/name_ring/is_named and the ringed rows in the
avatar_ring example sheet.
2026-07-02 22:31:06 -04:00
2ro 54344bd1d3 android: one-shot payment-requested notification (id=3)
Fire a separate high-importance system notification when an incoming
payment request (Invoice1 -> SurfaceRequest) is ingested over nostr,
mirroring the existing received-payment notification (id=2). Fail-open on
a missing JNI handle; fires once per not-yet-seen slate. No-op off Android.

Also add examples/tunnel_measure.rs, a dev harness for measuring the Nym
read tunnel (cold connect + warm per-fetch latency over the real transport).
2026-07-02 22:19:23 -04:00
2ro 23bb845689 nym: major transport speedup (throughput + reuse), money path unchanged
The in-process smolmix tunnel was far slower than the old SOCKS5 model.
Fixes:
- smolmix TCP buffers 8KB -> 256KB and burst 1 -> 64: bulk throughput
  ceiling rises ~32x (was capping relay backfill and JSON reads at a few
  KB/s).
- Read tunnel runs a high-traffic mixnet profile (cover traffic off, higher
  send rate, fewer reply SURBs) for lower latency; per-hop mix delays kept.
  The scoped-exit money-path client is separate and unchanged.
- HTTP over the mixnet now reuses connections (keep-alive pool) instead of a
  fresh TCP+TLS+HTTP handshake per request - fixes slow price and username
  reads.
- DNS prewarm no longer skips on cold start and serves stale-while-revalidate
  for known hosts, so a dial never blocks on DoT/DoH.

Money path (streamexit.rs, transport.rs) byte-for-byte unchanged.
2026-07-02 19:57:35 -04:00
2ro aa39737d3b nym: fast interactive reads + faster reconnect (no money-path change)
Profile/username/NIP-05 reads were ~10s and the tunnel stalled 20s on a
dead gateway. Fixes:
- Profile/accepts/dm-relay fetches stream scoped to their dial set and
  return on the first matching event instead of waiting for every relay
  (or the full timeout) - the ~10s nprofile search.
- HTTP over the mixnet is tunnel-first, scoped-exit only as fallback when
  the tunnel is not up (NIP-11/price/name lookups are public data).
- Name re-verify interval 78s -> 6h (was a debug leftover churning reads).
- Discovery relay NIP-11 probes run in parallel, not sequentially.
- Tunnel build timeout split from the exit dial cap: build 20s -> 10s
  (env GOBLIN_NYM_BUILD_TIMEOUT) so a dead gateway is abandoned fast; the
  exit money-path dial stays 20s.
- Cold start brings the tunnel up first, then prewarms the exit once after
  publish (grant sequencing preserved).
- NIP-05 search bounded to 15s instead of hanging up to ~90s.

Money path (transport.rs, streamexit.rs) byte-for-byte unchanged.
2026-07-02 18:57:52 -04:00
2ro 5733b9a894 update: fix in-app update download names (android-arm, linux tar.gz)
The in-app updater built the ARM Android download as goblin-<tag>-
android-arm.apk and the Linux download as .AppImage, but releases ship
goblin-<tag>-android-arm.apk (this build on) and a linux .tar.gz. On an
older release the ARM apk was named -android.apk, so the in-app update
404'd. Releases now name the ARM apk -android-arm.apk and the updater
targets the tar.gz, so Android and Linux in-app updates resolve.
2026-07-02 16:59:07 -04:00
2ro 8f1a955f9a docs: remove internal planning/meta docs, keep user and technical docs 2026-07-02 16:54:45 -04:00
2ro ae4306febe ci: fetch the nip44 crate for release builds
Adds a fetch-nip44 composite action (clones 2ro/nip44@v3 into ../nip44)
and runs it alongside fetch-nym in the linux/windows/macos jobs, so the
`nip44 = { path = "../nip44" }` dependency resolves on the runners.
2026-07-02 15:41:42 -04:00
2ro 989fd5b04a goblin: name avatars get a thin yellow outline, not a conic gradient
The username-seeded conic-gradient ring is replaced by a single thin,
light-yellow outline that hugs the avatar circle (image or grinmark
orb). It only appears for a claimed name (never a bare npub) and simply
signifies 'this identity has a name' - no per-name color, no gap, no
gradient. Drops the now-dead ring_params seed.
2026-07-02 15:41:42 -04:00
2ro acf9a140f6 goblin: scan an amount from a pay-URI QR (GoblinPay parity)
The recipient QR gains optional amount/memo query params:
  nostr:<nprofile>?amount=<decimal GRIN>&memo=<percent-encoded>
Scanning a GoblinPay checkout QR now prefills the amount (and the send
note) instead of only the recipient. Bare nostr:<nprofile> is unchanged.

The parser (src/nostr/payuri.rs) is pure and fail-closed over untrusted
scan input: amount is accepted only if the wallet's own
amount_from_hr_string parses it strictly positive; memo is percent-
decoded, control-stripped and 256-byte capped; only the nostr: scheme
unlocks params; 4096-byte cap; embedded NUL rejected; any problem
degrades to recipient-only. PREFILL ONLY - the picker resolver and the
amount/review confirm still gate every send; nothing auto-advances.
2026-07-02 15:25:57 -04:00
2ro db793bc13d goblin: log in to nostr apps with your nsec (Advanced -> Nostr key)
Advanced gains a password-gated Nostr key card: reveal the wallet's nsec,
Copy it, or show it as a QR. Scanning that QR (or pasting the copied
nsec) into a nostr app's private-key login - e.g. magick.market - signs
you in with the same identity the wallet uses. The nsec is derived on
demand behind the wallet password and never persisted; wrong password
cannot leak it. Six advanced.* strings added across all six locales.
2026-07-02 15:03:32 -04:00
2ro 71bf9b90e5 Goblin Build 123: fast relay connect after app updates
Fix: a relay-pool cache written by an older build parses fine but lacks
the scoped-exit addresses, which silently disabled the fast money path
for up to 7 days after an update - relay connects rode the slow public
path for minutes. The cache is now ignored and replaced when it lacks
exits, and the pinned pool takes over immediately.

Fix: the scoped-exit mixnet client now prewarms at cold start, so the
sequencer's head start is real (it previously waited on a client that
nothing had started until the first relay dial).

Build: wallet submodule repinned to the upstream grim branch tip
(c2db754) - the previous pin was deleted upstream, breaking CI checkout
and fresh clones. Policy: submodules stay pinned to GRIM's sources.

Lean: drop verified-dead code (avatar upload pipeline, legacy UDP DNS
path, legacy watchdog, duplicate TLS config, one-use error type, dead
store helpers).
2026-07-02 14:34:13 -04:00
2ro 5869ff78be android: drop the notification enable-node action; re-enable macOS release CI
The Android keep-alive notification's background job is the light Nostr-over-Nym
payment listen ("Listening for payments"); the heavy integrated node is no
longer STARTable from it (Goblin defaults to an external node). Only STOP is kept
as a safety valve. Also re-enable the macOS release job (release: published) so a
published release attaches a universal .app build on the native runner.
2026-07-02 13:38:40 -04:00
2ro 300d9cea4c nym: restore scoped exit as fast money path + relay.floonet.dev cutover
The 180s cold connect was the public-IPR path (nested TCP over the mixnet) plus
the public-exit lottery -- NOT the scoped exit, which was removed by mistake in
3372202. Restore the co-located MixnetStream exit: a relay connects in 0-2s over
it (measured), vs 15-180s over the tunnel.

- Cold-start sequencer: streamexit::is_ready + a bounded EXIT_HEAD_START gate in
  nymproc so the exit client claims its Nym bandwidth grant before the tunnel,
  avoiding two-client serialization (~1min otherwise). No SDK surgery.
- Pin relay.floonet.dev as the primary money-path relay (with its co-located
  exit) in PINNED_POOL; keep relay.goblin.st as a secondary through transition.
- E2E: a funded 0.1 GRIN payment finalizes in 6s over the exit across two
  different Grin nodes (grincoin.org, main.gri.mw).
2026-07-02 12:50:33 -04:00
Goblin 337220299f nym: remove the dead scoped-exit egress
The exit is unpinned everywhere, so the streamexit module + the exit_for/
exit_connect forks in transport + http were dead code. Remove them; the
wallet's only mixnet path is the smolmix tunnel.
2026-07-02 10:06:50 -04:00
Goblin 12f78f3af7 send: allow pasting an npub in the Send-to field (issue #1)
The recipient field had a QR-scan button but no paste, so on Android there
was no reliable way to paste an npub. Add the paste button (same one the
slatepack field uses).
2026-07-02 09:56:40 -04:00
Goblin bb2e8602ff relay pool: drop the scoped-exit pin (convenience first)
The money-path exit ran a second mixnet client whose cold bootstrap blocked
first-connect ~100s per session. Unset the exit so the wallet reaches
relay.goblin.st over the fast smolmix tunnel (still fully over the mixnet).
Privacy backbone unchanged; the exit stays deployed for a non-blocking
redesign later.
2026-07-02 09:48:37 -04:00
Goblin 78e141f319 avatar ring: sit around the orb with a clear gap, thinner
The username ring overlapped the gradient orb's edge. Inset the orb and
add a real gap so the ring sits around it without touching; thin the ring
(size*0.045) since it no longer needs to overlap.
2026-07-02 09:35:56 -04:00
2ro c701f0f480 Floonet scoped Nym exit + NIP-44 v3 + wallet polish
Money path:
- Scoped, unbonded Nym exit for the money-path relay: the wallet dials a
  relay operator's co-located exit over a MixnetStream (src/nym/streamexit.rs)
  which pipes to its one relay; hostname-validated TLS end to end, no public
  DNS. Anchor + fallback (never pin-only): any exit failure degrades to the
  smolmix tunnel. relay.goblin.st's exit address is pinned in the relay pool
  (src/nostr/pool.rs) and the maintainer gist so it bootstraps offline.
- STREAM_SETTLE bridges the open-before-accept gap so the first TLS byte is
  not dropped into a stalled handshake.
- Verified end to end: two wallets complete a real gift-wrapped Grin payment
  through relay.goblin.st over the exit, finalized + posted on mainnet
  (src/wallet/e2e.rs, ignored live test).

Encryption:
- Adopt NIP-44 v3 for the NIP-17 gift-wrap path (G4): src/nostr/wrapv3.rs,
  nip44 path dep; v3<->v3 and v3->v2 interop.

Also: mix-DNS (src/nym/dns.rs), full localization pass, GUI polish,
avatar-ring example, Android icon/script updates, GRIM deviation notes,
xrelay + connect-timing tests.
2026-07-02 04:17:59 -04:00
2ro 1e8e0f6526 settings: point the GRIM link at the live upstream (GetGrin/grim), not the stale ardocrat fork 2026-06-17 23:06:52 -04:00
2ro 9262d7429b nip05: drop the name-transfer client — names are never transferred
The name service no longer exposes /api/v1/transfer (removed server-side), and
the model is release-and-reclaim, not transfer: on a key rotation you release
the old name and re-register (or import your existing identity). Removed the dead
nip05::transfer() client fn (it had no callers) and the "transfer" wording in the
README.
2026-06-17 22:10:35 -04:00
2ro 36e63d4751 sync upstream Grim: bump node + wallet, adapt tx_log_iter
Upstream Grim advanced past the fork base with a node + wallet version update
(b51a46b → its "node + wallet: update to latest versions"). Bumped both
submodules (node → bce5a71, wallet → c2db754) and applied the one source
adaptation that update requires: `tx_log_iter()` now yields Result items, so the
three call sites filter Ok + unwrap before use. The upstream Tor/arti-0.43 commit
is skipped — Goblin removed Tor entirely.
2026-06-17 22:10:25 -04:00
2ro f0b5410c13 goblin: approving a payment request goes through a hold-to-accept review
Tapping Approve on an incoming request no longer pays immediately — it opens a
full-surface review (who's asking, amount, note, live network fee, privacy,
delivery) with a hold-to-accept gesture, mirroring the send review. Paying a
request is a spend, so it should confirm like one. The NostrPayRequest is
dispatched only when the hold completes; decline is unchanged, and an
over-balance request disables the accept. New goblin.request.review_title /
hold_to_accept / hold_accept_hint across all six locales (drift green).
2026-06-17 15:08:51 -04:00
2ro 84cc9d663b docs: README no longer claims an avatar service — avatars are client-derived
The identity service stores names + pubkeys only; avatars are rendered
client-side from the pubkey (npub gradient + first letter, else the Grin mark).
Dropped the stale "avatar service"/"avatar pipeline" copy and the "avatar
fetches" mention from the mixnet-traffic line.
2026-06-17 12:05:13 -04:00
2ro 2235e64bac build(windows): embed the Goblin icon into goblin.exe via winresource
The .msi shortcuts already carry the icon, but the bare goblin.exe had none, so
Explorer/taskbar showed the generic exe icon. build.rs now embeds
wix/Product.ico (the yellow Goblin icon) as the exe's application icon resource.

Gated to Windows hosts: winresource is a `cfg(windows)` build-dependency and the
embed fn is `#[cfg(windows)]` (with a no-op stub otherwise), so Linux/macOS/
Android builds don't compile or run it — other releases are untouched. The embed
is best-effort (warns, never fails the build) in case rc.exe is unavailable.
2026-06-17 09:43:49 -04:00
2ro 2b5cb8ad55 ci(windows): build a WiX .msi installer, like GRIM (not just a bare exe zip)
GRIM ships a Windows .msi; we were only zipping goblin.exe. The Windows release
job now runs cargo-wix against wix/main.wxs (the cargo-wix default template:
WixUI_Minimal + launch-after-install), producing a proper installer whose
shortcuts and Add/Remove-Programs entry carry wix/Product.ico — the yellow
Goblin icon. WiX 3 is taken from the runner or installed via choco; --no-build
reuses the release exe so the embedded GOBLIN_BUILD number is preserved. We
upload BOTH the .msi and the portable .zip (matching GRIM). Windows-only change.
2026-06-17 08:48:01 -04:00
2ro 1fdbd80282 icons: regenerate every platform icon from the canonical Goblin sources
All app icons now derive from two sources, end to end:
  img/goblin-icon.png        gradient app icon (yellow gradient + black mascot)
  img/goblin-mark-black.svg  black mascot mark (vector) — Android adaptive fg

- scripts/gen_icons.sh rewritten: one run regenerates the desktop/egui window
  icon (img/icon.png), the Linux AppImage AppDir icon, all Android launcher +
  adaptive-foreground mipmaps, the WiX installer icon, and the macOS .icns.
  Dropped the dead goblin-mask*.png pipeline (the adaptive foreground is now the
  SVG mark composited by the OS over #FFD60A), so the script no longer references
  files that were removed.
- scripts/make-icns.py: new, dependency-free multi-resolution .icns builder
  (iconutil/png2icns aren't always present; ImageMagick alone emits one size).
- wix/Product.ico REPLACED with the Goblin logo (was a stale icon).
- Regenerated macOS AppIcon.icns, Linux goblin.png, all Android mipmaps,
  img/icon.png. Tracked the goblin-mark-{black,white}.svg sources.
2026-06-17 02:36:50 -04:00
2ro d0cb76fa02 img: drop unused legacy/GRIM images
Remove six images with no code or build references — leftovers from the GRIM
era and superseded logo variants:
  cover.png            old GRIM marketing cover (paw logo, "Grin · Tor")
  grin-logo.png        old Grin/MW smiley
  logo.png, logo_light.png   GRIM wordmarks
  goblin-logo-256.png  superseded
  goblin-logo2-256.png superseded (the app uses goblin-logo2.svg + -48.png)

Kept (all referenced): icon.png (main.rs window icon), goblin-logo2.svg + -48.png
(in-app marks), goblin-icon.png (gen_icons.sh source). The in-progress icon
rework (goblin-mask*/goblin-icon-512 pruning, new goblin-mark-*.svg) is left
untouched.
2026-06-17 01:23:52 -04:00
2ro 991670d863 nostr: pause the @name re-verify sweep while the app is backgrounded
The 78s sweep fired regardless of whether anyone was looking, spending mixnet
round-trips in the background. Gate it on a frame heartbeat: the GUI stamps
crate::mark_frame() each draw (with a light ~2s repaint cadence so it stays
fresh while visible); when the app is backgrounded eframe stops drawing and the
stamp goes stale, so crate::app_foreground() reads false and the sweep skips.

The skip deliberately does NOT advance last_name_sweep, so the first tick after
the app returns to the foreground runs the sweep immediately — catching up on
resume rather than waiting out another full interval. Heartbeat lives at the
crate root so nostr reads it without depending on the gui module.
2026-06-16 22:58:07 -04:00
2ro 55b78b78ef macOS app icon = Goblin gradient; keep a contact's name across a decline
- The macOS bundle still shipped the old GRIM paw AppIcon.icns. Regenerated it
  from Goblin-Logo-Gradient-SMALLER.png (the yellow-gradient goblin mark) as a
  proper multi-resolution PNG-icns (16–512), so Finder/Dock show the Goblin icon.

- Declining (or cancelling) a request never re-resolved the counterparty, so
  their @name could drop to a bare npub just because the request didn't go
  through. handle_wrap now re-resolves the counterparty after a void — cheap,
  authoritative via the by-pubkey reverse lookup, and a no-op for anonymous keys.
2026-06-16 22:15:43 -04:00
2ro 919cfcb71e nostr: resolve a contact's @name via authority reverse lookup first
The requester still saw a bare npub for the payer because resolving a name
depended on fetching the peer's kind-0 off a relay (a nostr REQ with tight
timeouts over the private transport) before it could verify the NIP-05. When
that fetch doesn't land, resolution never starts — even though the name server
knows the name perfectly well.

resolve_contact_identity now asks the home authority directly:
GET /api/v1/by-pubkey/{hex} → the active @name for that key, in one HTTP
round-trip, with no profile fetch. That answer is authoritative, so the name is
set verified immediately. The kind-0 + verify path stays as a fallback for
FOREIGN authorities (which the home server can't speak for) and is still what
CLEARS a released/reassigned name. New nip05::name_by_pubkey helper.

Pairs with the goblin-nip05d by-pubkey endpoint. Verified by me only as far as
compile + unit/i18n tests; the live two-party resolution is the owner's call.
2026-06-16 20:01:20 -04:00
2ro d60e71d1e0 onboarding + nostr: healthy default node, claim feedback, identity import, resolve payer name
Four field-reported issues from a fresh install + a friend payment:

- Default node was grincoin.org, whose foreign API was returning "rpc call
  failed" — onboarding sync died with an un-retryable error. Lead the node
  list with the verified-healthy api.grin.money (external.rs) and use it as the
  onboarding default (was grincoin.org); grincoin.org stays in the list.

- Claiming a username gave no feedback and the identity card kept showing the
  npub. The card now shows the @name + a seal check once claimed, and a clear
  "name is yours" success card replaces the claim form before Open wallet.

- A returning user who restores a seed gets a fresh random nostr key, so their
  old @name couldn't come back. Offer "Import it" in the identity step: paste an
  nsec or pick a .backup file (reuses the wallet password just set) to keep the
  existing key + username.

- The requester side of a request never resolved the payer's @username — the
  FinalizePost ingest arm skipped ensure_contact/resolve_contact_identity, so a
  completed request showed a bare npub for the payer. Resolve on finalize like
  every other ingest path.

i18n: claimed_title/claimed_blurb + import_existing/import_title/import_blurb
across all six locales; drift test green.
2026-06-16 18:50:19 -04:00
2ro 24abc7e7b3 docs: TRANSACTIONS.md — full payment lifecycle, statuses, ingest guard, cancel/expiry/recovery 2026-06-16 12:55:51 -04:00
2ro 11033b93fe Federation, note modal, save-to-device, onboarding fixes + UI polish
- Configurable name authority (Settings → Identity → Name authority): bare
  names resolve there, own-domain names show bare, foreign verified names show
  'name · domain' with a check — no '@' anywhere. Lets bob@otherdomain pay
  alice@goblin.st. Home domain derived from the configured server.
- Note entry is now a modal that floats above the soft keyboard (dimmed
  backdrop) instead of an inline editor the keyboard covered.
- Backup export SAVES to a chosen location (Android CREATE_DOCUMENT / desktop
  save dialog) instead of opening the share sheet.
- Onboarding status-bar icons are legible again (white on the dark surface,
  not black); identity step is less wordy and drops the '@' prefix; claiming a
  name during onboarding now republishes kind 0 so it's visible immediately.
- App-open name re-verify sweep (persisted, runs if >78s since last).
- Advanced 'Manage node connection' opens GRIM's native Connections UI.
- Manual slatepack paste: removed the QR icon. Pay screen: bolder, bigger ツ.
- Localized new strings across 6 locales.
2026-06-16 03:22:08 -04:00
2ro dfbd85c7b3 nostr: keep contact @usernames fresh; clear released/reassigned names
Cached names were verified once and never re-checked, so a contact who
released or changed their username kept showing the stale name forever.
Re-validate names on a 78s sweep (capped per tick to bound mixnet lookups):

- nip05::check — tri-state Verified/Mismatch/Unreachable, so we only clear on
  a definitive server answer (released, or reassigned to a different key),
  never on a network blip.
- resolve_contact_identity now re-checks names older than the freshness window
  and clears nip05 + nip05_verified_at on Mismatch (a user petname is kept);
  display falls back to the npub automatically.
- A periodic sweep in run_service re-verifies the stalest due contacts.

Tests for the tri-state parsing and the clear-keeps-petname logic.
2026-06-16 01:39:54 -04:00
2ro 7eb0683646 docs: drop the Cash App comparison; remove avatar mentions from the README
Rephrase the README as 'pay-by-username' and scrub the Cash App name from
code comments. Also drop the leftover 'hosted avatar'/'avatars' wording —
identities use generated identicons, not uploaded pictures.
2026-06-16 01:03:05 -04:00
2ro 9dba2163fa ui: profile/contacts, requests spinner, receipt fixes, backup file, send + advanced
- Republish kind 0 right after claiming a username (was invisible until restart).
- Request card shows a 'Paying…' spinner instead of a dead greyed button.
- Receipt: count confirmations 1/10…10/10 (was stuck at 0/10, jumped to done
  at one block); hide the network-fee row on received payments.
- Settings: one 'Back up to a file' flow (GOBLIN-*.backup) replacing copy-nsec
  / copy-JSON; import accepts a .backup file via the native picker.
- Advanced: 'Run your own node' opens the node-connection page (incl. an
  integrated-node option); Repair confirms in accent; Restore warns in red.
- Send: drop the 1/10/100/Max chips; Note becomes an Add-note editor.
- Remove the dead profile-picture upload UI and scrub picture wording.
- Localize all new strings across 6 locales; drift test green.
2026-06-16 00:32:02 -04:00
2ro 313a14b82c wallet: contacts on send, request-approve states, .backup create/import
- Create/refresh a contact when you SEND (not just receive) so people you
  pay show up under Suggested and resolve their @name.
- Approve-request: set SENT on the Standard1 success path and fail_send on
  the error + non-payable paths, so the button never sticks greyed.
- create_nostr_backup() + import of the encrypted .backup envelope.
2026-06-16 00:31:50 -04:00
2ro 222f149fc2 nostr identity: single fully-encrypted .backup envelope
Add to_encrypted_backup / from_encrypted_backup: the secret key is the
password-protected NIP-49 ncryptsec, and the rest of the identity is
NIP-44-sealed to our own key — so a GOBLIN-*.backup file leaks no npub or
username, yet any Goblin wallet reopens it with the password.
2026-06-16 00:31:37 -04:00
2ro 6ea94989bf nostr: fix profile loading over the relay
Profiles never loaded when scanning a bare npub (username/avatar stayed
blank) even though the relay stores and serves the kind-0 fine. Two causes:
fetch_profile_blocking ran on a throwaway current-thread runtime that can't
drive the relay connections (which live on the service runtime, behind the
custom Nym mixnet transport), and it only dialed nprofile hints, never the
user's own default relays. Run the fetch on the service runtime via a stored
Handle, and always dial the default relay set (incl relay.goblin.st).
2026-06-16 00:31:27 -04:00
2ro a35fb7956c Android: dark status-bar icons on the yellow GRIM/onboarding header
In dark theme the wallet list, app settings, wallet creation and onboarding
leave the bright accent-yellow title_panel_bg showing under the status bar,
but status_bar_white_icons() returned the dark-theme default (white) — white
icons are illegible on yellow. Force dark icons on every non-Goblin-surface
screen (the Goblin surface covers the inset and sets its own per-tab flag).
2026-06-15 21:42:43 -04:00
2ro 6a0c2565b5 Review page: show live network fee for the amount (priced like GRIM's send modal) 2026-06-15 21:18:34 -04:00
2ro b7c3b95f51 Copy buttons: haptic tick + transient 'Copied' confirmation 2026-06-15 21:15:26 -04:00
2ro 22292ef79c Onboarding skips node step; internal node moves to Advanced; WALLETS->GOBLIN
- Onboarding goes Intro -> wallet setup directly (connected to a public node
  instantly); the node-choice step is retired.
- External node settings stay in Settings; 'run your own node' (integrated) now
  lives in the Advanced submenu.
- Wallet-list: title reads GOBLIN (was WALLETS), dropped the redundant GOBLIN
  wordmark under the mark, build number kept but smaller.
2026-06-15 21:07:42 -04:00
2ro cc59be0834 android.sh: fix flavor default ([[ $flavor ]], not literal 'flavor')
The empty-flavor test compared the literal string 'flavor', so a no-flavor
invocation left the APK output path as apk//debug/app--debug.apk and the
rename failed. Default to 'local' correctly.
2026-06-15 20:20:15 -04:00
2ro 3eff81e18d macOS: package as Goblin.app bundle, not a bare binary
The macOS CI job lipo'd the universal binary and zipped it raw, so users got a
naked 'goblin' executable instead of a double-clickable app. Assemble the
universal binary into the existing macos/Goblin.app bundle (Info.plist + icon),
ad-hoc codesign it (required for the arm64 slice to run on Apple Silicon after
lipo strips the per-arch signature), and ditto-zip the bundle. Remove the stale
tracked _CodeSignature that would otherwise make the app read as 'damaged'.
2026-06-15 20:18:41 -04:00
2ro dbc988f9ae Default to relay.goblin.st (strfry) instead of nrelay.us-ea.st
The Goblin relay moved to strfry at relay.goblin.st; point the wallet's default
DM relay set there (public relays damus/nos.lol stay as redundancy).
2026-06-15 20:00:13 -04:00
2ro 9768de2fbd Add manual 'Cancel payment' to reclaim a stuck outgoing send
A Goblin payment locks the sender's outputs until the recipient replies (S2)
and we finalize+post. If the recipient never connects to nostr, the funds stay
locked until the 24h auto-expiry. This adds a manual Cancel that reclaims them
on demand (after a 10-min grace, or immediately if the send never reached a
relay), marks the payment Cancelled, and best-effort voids it to the recipient.

- WalletTask::NostrCancelSend: authoritative tx lookup; refuses if already
  finalized/confirmed (race); marks meta Cancelled BEFORE cancelling the grin
  tx; serialized with nostr_finalize_post via a per-service lock so a cancel and
  a concurrent S2 finalize can't both commit.
- nostr_finalize_post returns Ok(false) (skip, no retry/re-post) when the tx is
  cancelled or the meta is Cancelled — covers the tx-list cancel path too.
- decide() already drops a late S2 on a Cancelled meta (new unit tests assert
  it); recipient-side void marks a received payment Cancelled for display WITHOUT
  deleting the output (a malicious sender could void-then-post otherwise).
- Void-before-S1 ordering handled via a (slate,sender)-bound marker.
- Receipt: tap-twice 'Cancel payment' with caveat + outcome notice; honest
  'Waiting for X to receive…' label; first-class Cancelled status. 6 locales.
- cancel_grace_secs config (default 600).
2026-06-15 19:34:44 -04:00
2ro 2e6cff9eeb Receive screen: copy/share the npub, never a grin address
The right button copied the grin1 slatepack address and the left copied the
nprofile — both wrong for 'how people pay me'. Now: left = Share a friendly
'Pay me on Goblin (goblin.st) — npub1…' message, right = Copy the bare npub.
New receive.copy_npub / receive.share_message keys across all six locales.
2026-06-15 17:28:26 -04:00
2ro ba504aa266 Cap claimed names at 20 chars to match the name authority
The nip05d authority now enforces a 3..=20 length; align the wallet's claim
validation (onboarding + settings) and the 'Names are 3-20 chars' hint across
all six locales so the wallet never offers a name the server will reject.
2026-06-15 16:12:26 -04:00
2ro 49fbebd4ce Docs: drop the displayed @ before usernames (kept internally for lookup) 2026-06-15 12:07:31 -04:00
2ro 72181ec9eb Build 83: gate vendored OpenSSL to Linux/Android so Windows builds again
The vendored 'openssl' dependency existed only to statically link OpenSSL on Linux/Android (where native-tls uses it). In the global [dependencies] it also forced openssl-src to compile on Windows and macOS, which don't use OpenSSL at all (SChannel / Security.framework). On the Windows MSVC CI runner that build fails: openssl-src's perl Configure runs under Git-Bash's MSYS perl, which lacks Params::Check / Locale::Maketext::Simple. Gating the dependency to cfg(any(linux, android)) removes the pointless, fragile build on Windows/macOS and keeps the self-contained static link on Linux/Android. No behavior change on any platform.
2026-06-15 03:21:27 -04:00
2ro 5005d7cb2d Build 82: names without @, wallet management + Advanced, list header, sturdier receive
- Drop the displayed @ prefix everywhere (identity picker, slatepacks page, all copy); @ stays internal for lookups/avatars.
- Settings: move wallet management to the foot — Switch wallet (deselect, stays unlocked), Lock wallet, and a new Advanced page mirroring GRIM's recovery tools (repair, restore-from-seed, reveal recovery phrase, delete).
- Restore the wallet-list title header (reachable settings gear, Pay-screen accent yellow).
- Transport: a transient receive/finalize failure no longer marks the gift wrap processed, so an incoming payment is retried on catch-up instead of being silently lost; finalize-post is now retry-safe (re-posts an already-finalized slate).
- Guard a latent panic on a short sender key in ensure_contact.
- Add more healthy public grin nodes (mainnet + testnet) for redundancy.
- Default first-run onboarding to the Instant connection (public node), shown first.
- Tidy leftover Tor remnants left from the fork.
2026-06-15 02:17:45 -04:00
2ro 96daed3c84 Build 81: show names everywhere, no @, tidier pay entry
Resolve a counterparty's @username on every interaction, not just incoming
requests — receives, sends and requests all kick off a verify-and-cache, plus a
one-time backfill on wallet open — so activity and the recent strip show names,
not bare npubs. Usernames now render WITHOUT the @ (kept internally for avatar
lookup); the recent row ellipsizes past 8 chars and centers the name under the
avatar, while activity shows the full name.

Pay screen: the numpad now sits above the note so the soft keyboard can't cover
it (and tapping the pad dismisses the note's keyboard), and a no-op key — a
second dot, a 0 on a leading zero, the 9-decimal cap — fires a short error
haptic instead of doing nothing silently.
2026-06-15 01:14:36 -04:00
2ro f715149302 Build 80: show the real balance and explain failed sends
The balance hero only showed amount_currently_spendable, so a wallet whose
funds were still confirming read 0 while GRIM showed the real total — the source
of the "Goblin says 0" confusion. It now shows the TOTAL (matching GRIM) with an
"X available - Y confirming" breakdown when some isn't spendable yet.

A send or approve that hits NotEnoughFunds (coins from a recent payment still
confirming, ~10 min) now says exactly that instead of a blank "Couldn't send",
and the Approve button no longer stays greyed forever — it un-greys on failure
with the reason shown, so the user can retry once funds clear. The relay-dial
cap for cross-relay delivery drops 12s to 6s so the first send isn't sluggish.
2026-06-15 00:21:10 -04:00
2ro af30af48e4 Build 79: deliver payments and profiles across different relays
A send/request to a counterparty on relays we don't already hold failed with
"relay not found": NIP-17 publishes to the RECIPIENT's relays, but send_*_to
rejects any relay not in the pool. connect_relays() now adds and dials the
target's relays (from their kind-10050 or an nprofile/QR hint) before sending,
so the gift wrap actually reaches their inbox. Same fix lets profile/@username
lookup find a kind-0 that lives only on the target's own relays — fetch_profile
takes relay hints and dials them first. Sidebar handle font now scales with
length so short @names are legible (was a fixed, too-small 11px).
2026-06-14 22:47:02 -04:00
2ro f0b854171c Build 78: honest transport labels, request decline/cancel, NIP-05 on requests, full localization
Settings now says "Manual transaction" and the privacy row reads "Messages &
lookups" opening a new Network privacy page that tells the truth: messages,
names, price and avatars ride the Nym mixnet; the grin node connects directly.
README and lander updated to match.

Requests are messages, payments are final: declining a request now sends the
requester a void control message (NIP-17), a requester can cancel a request they
sent (cancels the local invoice and notifies the payer), and incoming requests
resolve the sender's verified @username instead of a bare npub. The Requested
amount on the success screen is centered. New NostrDecline/NostrCancel tasks and
a goblin-action control message carry it, bound to the stored counterparty.

Localization: every Goblin-screen string moved to t!() keys (370 keys) and
translated into de/fr/ru/tr/zh-CN, guarded by a key/placeholder drift test.
System-locale auto-detect now matches region locales like zh-CN.
2026-06-14 21:44:24 -04:00
2ro 0644807f51 Build 77: smaller sidebar avatar so the npub fits one line
Shrink the sidebar identity chip's avatar (36→28px) and handle (12→11px) to
free horizontal space, so the npub/handle stays on a single line.
2026-06-14 18:46:22 -04:00
2ro 8e1b3ce847 Build 76: split mixnet vs nostr status; tidy the identity chips
- The profile card drops the duplicate npub line and shows two statuses: the
  mixnet ("Connected over Nym", ~instant) and under it the nostr relay
  ("Connected to nostr" / "Connecting to relays…"). The relay connection is the
  slower step, not the mixnet.
- New cheap cached nym::is_ready() flag backs the mixnet status (no per-frame
  TCP probe), set when the SOCKS5 proxy comes up.
- Sidebar identity chip: smaller handle so the npub stays on one line, with a
  matching Nym/relay status.
2026-06-14 18:23:00 -04:00
2ro fb4f27a88f Build 75: no pairing by default; price back over the mixnet
- Pairing defaults to Off on first run: no conversion is shown anywhere and no
  price request leaves the device until the user opts into a pairing. Off is a
  picker option and the choice persists.
- The rate fetch goes back over the Nym mixnet (reverting Build 71's clearnet
  route) — it's opt-in and infrequent, so it stays private and doesn't get in
  the way of slatepacks. CoinGecko's free-tier rate limit (shared exit IP) rules
  out live/1s polling, so it keeps the slow cache.
- Small note on the Pairing page that rates fetch over the mixnet.
2026-06-14 18:08:23 -04:00
2ro e5d1f0c6db Build 74: Pay header — bigger black QR + profile avatar, consistent sizes
On the Pay screen the scan QR drops its dark puck (now a bigger black icon
directly on the yellow), the user's profile picture sits to its right as a link
to the settings/profile page, and the goblin mark is sized to match — all three
controls about 40px.
2026-06-14 16:07:53 -04:00
2ro f5c449463b Build 73: chromeless Pay-screen navbar
On the Pay surface the 3-item bar drops its floating pill + shadow — Wallet,
the center ツ, and Activity sit dark directly on the yellow, Cash App-style.
Other surfaces keep the floating pill.
2026-06-14 15:41:44 -04:00
2ro 7e3f335e79 Build 72: fix the Pay-screen brand marks
Correct the Build 71 misread of the design notes:
- Amount keeps the ツ unit mark (revert the goblin mark next to the number).
- The goblin mark replaces the "Pay" title at the top-left (tinted dark to read
  on the yellow surface).
- The bottom tab-bar strip turns yellow on the Pay surface, like the body.
- (Dark status-bar icons on the yellow surface kept from Build 71.)
2026-06-14 14:51:17 -04:00
2ro 726e96130c Build 71: clearnet price + update check, Pay-screen UI polish
Following upstream Grim's posture for non-sensitive metadata: the fiat price
preview and the update check now go direct over HTTPS instead of the mixnet, so
the price shows promptly without waiting on the mixnet bootstrap. Payments,
relays and identity stay mixnet-only.

- price.rs: fetch the rate over clearnet (plain reqwest), no proxy.
- update check: on by default like Grim, repointed at Goblin's own GitHub
  releases, build-number aware (is_update compares buildNN), Goblin asset names,
  GitHub User-Agent header.
- Pay screen: status-bar icons go dark on the bright yellow surface
  (status_bar_white_icons honours a per-frame yellow-surface flag); the hero
  amount shows the goblin mark as its unit in place of the ツ glyph.
2026-06-14 14:21:39 -04:00
2ro 851ae1c565 Build 70: refresh README + clean stale comments for the public release
README: drop the removed nym-socks5-client sidecar story (the SDK is linked
in-process now), add the in-process build steps + the manual-slatepack feature.

Comments: replace stale "sidecar" wording with the in-process SOCKS5 proxy,
drop leftover Tor references (Goblin routes over Nym), and trim the chattiest
working-notes to terse rationale.
2026-06-14 13:02:03 -04:00
2ro 5445b48a69 Build 69: fetch nym from our own mirror, not upstream GitHub
Point .github/actions/fetch-nym at git.us-ea.st/GRIN/nym (branch goblin =
upstream nymtech/nym @ b6eb391 + our Android webpki-roots patch) and clone it
directly. Drops both the upstream-GitHub fetch and the git-apply patch step —
the last third-party dependency in the build chain is now self-hosted. Removes
the now-unused ci/nym-webpki-android.patch.
2026-06-14 12:32:56 -04:00
2ro 56099539aa Build 68: build the GRIM way — integrate the code.gri.mw/DEV toolchains
Add scripts/toolchain.sh: fetches GRIM's canonical build toolchains (custom
NDK r29, zig, appimagetool + type2 runtime, and optionally the Android SDK /
gradle / osxcross) into a gitignored .toolchains/ and writes env.sh.

linux/build_release.sh and scripts/android.sh now source .toolchains/env.sh,
preferring the DEV toolchains and falling back to system installs:
- Android links against the custom NDK r29 (rebuilt LLVM), producing
  16 KB page-aligned .so libraries — required by the Play Store.
- The Linux AppImage cross-builds with the DEV zig + appimagetool; bindgen is
  pointed at the host kernel headers so v4l2-sys finds linux/videodev2.h under
  zig's glibc-2.17 sysroot.

Also fix the stale .gitignore AppRun entry (Grim.AppDir -> Goblin.AppDir).
2026-06-14 11:55:09 -04:00
2ro bb3b8c4ecc Build 67: Pay over-balance shake, manual slatepacks, green CI, slimmer builds
Pay: stop reddening the amount while typing — requesting more than you hold is
a valid request, so the digits stay black. Pressing Pay without enough funds
now shakes and briefly flashes the amount red and buzzes the phone, then
settles back. Bigger scan-to-pay puck.

Settings: the username "@" moves inside the field as the "@yourname"
placeholder (a leading "@" the user types is stripped). Mixnet routing is
shortened to "All traffic" and flagged in the privacy color. The Build row
links to GitHub releases. Network reads "MW + Nym mixnet + nostr". The Nym
third-party row shows the linked SDK version instead of "socks5".

Wallet: expose GRIM's native by-hand slatepack flow as an advanced
"Slatepacks" page — paste to receive/pay/finalize, or create a payment
slatepack to hand over. The fallback for when a payment can't ride a @username.

CI: fix the red GitHub builds. nym-sdk is a path dep on ../nym, which the
runners didn't have. A composite action materializes the pinned upstream nym
commit plus Goblin's small Android webpki patch before each build; aws-lc-sys
uses prebuilt NASM on native Windows.

Builds: strip release binaries, dropping ~17 MB of debug symbols from the
desktop build.
2026-06-14 06:25:44 -04:00
2ro 2578a35cf7 Build 66: disable clearnet update-check; drop sidecar build steps
Security (audit H-2): the legacy update check is OFF by default. It hit
code.gri.mw (GRIM's gitea) directly over CLEARNET via the old HttpClient —
leaking "this user runs Goblin" metadata on every wallet-list view, which
defeats the nothing-clearnet mixnet model, and it pointed at the wrong
project's releases anyway. Opt-in only until reworked to run over the
mixnet against Goblin's own releases.

Build: with the Nym SDK linked in-process there's no sidecar binary to
embed or bundle. linux/build_release.sh drops the GOBLIN_NYM_UNIX_BIN
embed (AppImage is one self-contained binary); scripts/android.sh stops
bundling nym-socks5-client into jniLibs (the cdylib links nym-sdk
directly); scripts/nym-android.sh deleted.
2026-06-14 04:07:39 -04:00
2ro 63d5ca2b5f Build 65: link the Nym SDK in-process — no sidecar subprocess
Goblin now links nym-sdk directly and runs its SOCKS5 client on an
internal tokio runtime exposing 127.0.0.1:1080 — the same loopback seam
the transport already dials. There is no sidecar subprocess and no
bundled/embedded/sideloaded helper binary; the goblin process itself owns
:1080. This mirrors how GRIM links arti/Tor in-process. Verified live: the
mixnet comes up in ~1.4-2s (gateway persisted in ~/.goblin/nym, reused
across launches) and a relay connects in ~2s over it, with no separate
process.

- Cargo.toml: add nym-sdk (path dep on the local nym checkout, which carries
  the Android webpki-roots patch) + rustls with the ring feature.
- src/lib.rs: install rustls' ring CryptoProvider at startup. Linking nym-sdk
  pulls aws-lc-rs alongside our ring; with two providers present rustls 0.23
  won't auto-pick a default and tokio-tungstenite/reqwest panic on the first
  TLS handshake. nym uses its own explicit provider, so this only steers our
  relay/HTTP TLS.
- src/nym/sidecar.rs: replace the subprocess machinery with an in-process
  Socks5MixnetClient (persistent storage; ephemeral fallback) kept alive for
  the process lifetime on a dedicated runtime. Drops the binary lookup, embed
  extraction, init/launch, and child management.
- build.rs: drop the GOBLIN_NYM_*_BIN embed block (nothing to embed).
- src/nym/{mod,transport}.rs, src/nostr/{mod,avatar}.rs: docs now describe the
  in-process client; clear stray "old Tor/arti" wording (no Tor transport code
  remains — only grin-core's slatepack OnionV3Address, which is unrelated).

Also: named users now get the pubkey-seeded gradient background with their
initial composited on top (instead of the Grin mark) — gui identicon.rs gains
gradient_bg_svg and widgets.rs gains gradient_letter_avatar; avatar_any routes
named keys to it. Verified live with @nymgoblin.
2026-06-14 03:46:02 -04:00
2ro 8c48d2f5ce Build 64: embed the Nym sidecar into the Linux binary (single-file AppImage)
The Linux release no longer ships a loose nym-socks5-client beside AppRun.
It's baked into the goblin binary the same way the Windows build bakes it
into goblin.exe, and extracted to ~/.local/share/Goblin at first launch
(chmod +x on Unix). The AppImage is now one self-contained file with nothing
loose to misplace.

- build.rs: generalised the Windows-only GOBLIN_NYM_WIN_BIN embed to a
  cross-platform path. GOBLIN_NYM_UNIX_BIN embeds the Linux/macOS sidecar;
  Android never embeds (its sidecar rides in the APK's jniLibs).
- src/nym/sidecar.rs: the embedded const, extract_embedded_sidecar, and the
  binary_path extract branch now cover all non-Android targets, with a Unix
  chmod +x on the freshly written file.
- linux/build_release.sh: rewritten to set GOBLIN_NYM_UNIX_BIN, apply the
  glibc-2.17 zigbuild + CRoaring-AVX512/vendored-OpenSSL fixes, and assemble
  an AppDir with no loose sidecar.
2026-06-14 02:23:10 -04:00
2ro bfed0a1cb9 Build 63: connect over Nym in seconds, yellow Pay page, gradient-avatar fixes
Nym "Connecting..." for ~1 minute — root cause fixed. open() spawned the
wallet sync thread BEFORE init_nostr() created the nostr service, so the
sync loop's first pass at service.start() found no service and skipped
it; the service only started on the next sync cycle, a full SYNC_DELAY
(60s) later. Measured on the dev box: the gap between "identity ready"
and "starting service" was 62s, while the relay itself connects over the
mixnet in ~2s. Initialising nostr before start_sync collapses that gap
to ~1ms (verified 62s -> 1ms). The mixnet sidecar already warms up at app
launch, so the relay connect was the only thing waiting — and it waited
on loop timing, not on Nym. Added a "first relay Connected ~Nms" log line
so the timing is observable from logs going forward.

Pay tab is now painted in the yellow theme (Cash App-style brand surface)
regardless of the active theme, via a scoped theme override held across
the central panel so the fill and every widget pick up the yellow tokens
together.

Gradient avatar now renders for anonymous npubs on the onboarding
identity card and the mobile home header — both hardcoded a flat "N"
letter tile while Settings already showed the gradient. The header falls
back to the short npub so avatar_any takes the gradient branch.
2026-06-14 01:58:19 -04:00
2ro 63bf92f172 Build 62: fix the in-field QR scan icon not opening the scanner
Build 60 moved the scan-to-pay screen behind a new `scan_open` flag (for the
Scan | My Code toggle), but the recipient picker (Send to / Request from) still
opened the scanner by setting only `self.scan` — which no longer gates the
screen. So tapping the QR icon in the search field silently started the camera
and showed nothing. Set `scan_open` + the Scan tab there too (keeping `scan` so
the Scan branch does not double-start the camera).
2026-06-14 00:58:01 -04:00
2ro fc348c1843 Build 61: deterministic gradient avatars for anonymous npubs
Anonymous users (no @handle, no kind-0 picture) had a flat colored tile with an
N - meaningless, since their identity IS the key. Replace it with a pubkey-seeded
two-tone gradient + the Grin mark (avatar = f(pubkey)): same key -> identical
avatar on every surface, nothing to upload/store/sync. Ported the shared
reference (identicon.rs, f64 math, SHA-256 of the lowercase hex seed) and rendered
via egui SVG loader (cached per pubkey). avatar_any/activity_row now take the
npub/hex and use the gradient when the display name is an npub, the lettered tile
otherwise.
2026-06-13 23:56:31 -04:00
2ro ea70923e83 Build 60: Scan-to-pay gets a Scan | My Code toggle, yellow QR, native share
Cash App-style scan screen: a segmented Scan | My Code control (new w::segmented)
over either the camera or your own payment QR. My Code shows the @handle above a
big nprofile QR with the Goblin mark nested in a YELLOW center (was white — same
19% footprint, yellow reads as light to a scanner so the High-ECC recovery is
unchanged), and a native Share button. Added share_text to PlatformCallbacks
(Android ACTION_SEND text/plain via a new shareText JNI method; desktop falls
back to clipboard) to share the npub + nprofile link.
2026-06-13 23:15:51 -04:00
2ro 3ebae8807c Build 59: start the nostr service immediately on wallet open (fix slow connect)
The Nym/relay connection could take up to a full SYNC_DELAY (60s) to even start:
the nostr service was kicked off deep inside the wallet sync loop, behind the
grin node-sync checks and !sync_error, so opening a wallet left the profile on
"Connecting..." until the next 60s sync tick (or never, while the node errored).
Move the (idempotent) service start to the very top of the loop, right after the
open check - independent of node sync - so the connection comes up right away.
2026-06-13 23:15:36 -04:00
2ro ca038c6e14 Build 58: heavier Pay puck — Noto Sans JP Black weight ツ
Owner wanted more "black strength" on the puck mark; re-subset the same one
glyph from Noto Sans CJK JP Black (was Regular) for thicker, more confident
strokes. Same noto-tsu family / path, no code change.
2026-06-13 21:49:07 -04:00
2ro 6ce70aee7e Build 57: Pay puck uses a Noto Sans JP ツ instead of Gamja Flower
Owner disliked the Gamja Flower ツ style on the center Pay puck. Replace it with
Noto Sans JP's cleaner, more geometric katakana tsu — subset to ONLY that one
glyph (~1.7 KB) the same way the old one was, loaded as the "noto-tsu" family and
drawn at the puck (mod.rs). Drop the now-unused Gamja Flower font + its license.
2026-06-13 21:45:56 -04:00
2ro de7007269f Build 56: wait for the mixnet before dialing relays; no on-screen keyboard
Two fixes from live testing:

1) Stuck on "Connecting…": the nostr service dialed relays without waiting for
   the bundled Nym sidecar to be up. On a cold start a fast wallet-open beat the
   mixnet bootstrap, every relay failed, and nostr-sdk backed off — so the wallet
   sat on "Connecting…" long after the mixnet was ready. Now we wait for the
   sidecar SOCKS5 port before connecting (instant once it is warm). Verified:
   service start -> relay catch-up in ~5s; the flag (Build 55) reflects it within
   2s.

2) On-screen keyboard on desktop: upstream Grim pops its own virtual keyboard on
   desktop (no_soft_keyboard = is_android()), which looked wrong in the wallet
   flows and competed with physical typing (intermittent dropped/duplicated
   chars). Goblin now uses native input on every platform — Android IME via JNI,
   physical keyboard on desktop — by defaulting no_soft_keyboard to true. Verified
   on desktop: no on-screen keyboard, reliable typing.
2026-06-13 21:39:17 -04:00
2ro 6dff408766 Build 55: surface the Nym/relay connection promptly (fix stuck "Connecting…")
The nostr service only refreshed its connected flag inside the select!s
sleep(30s) branch, which restarted on every incoming notification — so the
flag could lag the real relay state by 30s+ (or, under steady event flow,
never update), leaving the UI stuck on "Connecting…" even though a relay
handshake over the Nym mixnet completes in ~2s (measured). Set the flag right
after the startup connect, and poll it on an independent 2s interval; the 30s
heartbeat work (persist last-seen, TTL prune) stays on its own cadence.
2026-06-13 21:23:09 -04:00
2ro 6621dc6aaa Build 54: Windows single-file + silent, logged sidecar
Address Windows feedback (visible console window; no diagnostics when the
mixnet stalled):
- Hide the sidecar console: spawn nym-socks5-client.exe with CREATE_NO_WINDOW
  so launching it no longer flashes a terminal.
- All-in-one: embed the Windows sidecar into goblin.exe (build.rs, gated on
  GOBLIN_NYM_WIN_BIN) and extract it to %LOCALAPPDATA%\Goblin at first run, so
  the release is a single self-contained .exe with no loose helper to misplace.
- Log the sidecar to ~/.goblin/nym-sidecar.log (all platforms) instead of a
  null sink, so a stalled bootstrap is diagnosable.

Verified under wine: goblin.exe extracts the embedded sidecar, launches it,
and it opens the SOCKS5 proxy on 127.0.0.1:1080.
2026-06-13 20:30:33 -04:00
2ro 329067e1c2 Build 53: Windows + Android support with a per-platform Nym sidecar
Ship the bundled nym-socks5-client on Windows and Android, not just Linux:
- sidecar.rs resolves the binary per platform — nym-socks5-client.exe on
  Windows; on Android the sidecar rides in the APK jniLibs as
  libnym_socks5_client.so and is launched from the native-library dir (the
  one exec-allowed path), located via NATIVE_LIBS_DIR.
- Restore a vendored, statically-linked OpenSSL. Upstream Grim got this from
  arti's static feature; dropping arti for Nym took it with it, which broke
  Android/cross builds (no system OpenSSL for the target) and left desktop
  dynamically linked to libssl. Inert on Windows/macOS (SChannel/Security.fw).
- android.sh bundles the sidecar into jniLibs per ABI; scripts/nym-android.sh
  cross-builds it. Onboarding copy: Tor -> the Nym mixnet.

Verified end to end on an x86_64 emulator: the sidecar extracts, launches,
initialises, and opens the mixnet SOCKS5 proxy on 127.0.0.1:1080.
2026-06-13 19:57:36 -04:00
2ro 0f46145f46 Build 52: drop the Pay keypad lower; NIP-44 + Mimblewimble-plus-Nym labels
- Pay tab keypad sits in the lower third (thumb reach) instead of floating in
  the middle with a big empty gap below it (pay_ui computes a drop spacer on
  phone layouts).
- Review/Confirm "Delivery" reads "NIP-44 encrypted, over Nym" (naming the
  actual gift-wrap encryption), and "Privacy" reads "Mimblewimble + Nym" on
  the review and the receipt — surfacing both the chain and network layers.

Verified live at 394px across the Pay tab, review, confirm and receipt.
2026-06-13 18:54:11 -04:00
2ro ba5ddf07ac Build 51: fit the balance, center the send flow, fix the success logo
On-device review surfaced UI overflow/centering issues at phone width:

- Balance hero shrinks to fit: amount_text_centered_ink measures the text and
  scales the font down when it would run off the edge (e.g. 0.47520721ツ).
- Send flow sits closer to the screen edges (smaller side gutters) and is
  properly centered: the Review/Confirm hero now uses a centered, wrapping
  layout instead of a fragile manual offset that clipped a long npub and
  threw off the centering. info_row values truncate instead of overflowing.
- Recipient picker: the suggested row shows the full npub in the grey subtitle
  (truncated to fit) instead of repeating the same shortened npub as the title.
- Success screen uses the goblin head (goblin-logo2), not the mascot mask.

Verified live at 394px across balance, recipient, review, confirm and the
success screen.
2026-06-13 15:55:02 -04:00
2ro c05074faac Build 50: production Nym requester; correct sender amount/fee; Nym copy
- Bake the always-on us-ea.st network requester into NETWORK_REQUESTER so a
  shipped wallet routes through the mixnet out of the box (override with
  GOBLIN_NYM_PROVIDER). Verified e2e: real wallet-to-wallet payment across two
  mainnet nodes (grincoin.org → main.gri.mw), recipient received the exact
  amount, all traffic over Nym.
- Fix the sender's displayed amount: a sent tx debits inputs and credits change,
  so debited-minus-credited is amount PLUS fee. Subtract the fee so the activity
  feed, receipt and history show the value that reached the recipient (0.01),
  not the fee-inclusive net (0.033). The receipt's "Network fee" now reads the
  real kernel fee instead of the change.
- Replace the remaining user-facing "Tor" copy with Nym (send/request review
  delivery line, connection status chips, onboarding, the Privacy settings row).

38 lib tests pass.
2026-06-13 15:15:41 -04:00
2ro c4d26d3b7f Build 49: auto-expire stale transactions; de-dupe receipt counterparty
Pending transactions that never complete now auto-cancel/expire after a
configurable window (NostrConfig::expiry_secs, default 24h; lower it in
nostr.toml to test). A sync-loop sweep (NostrService::expire_stale):

- cancels stale outgoing sends + invoices we paid via GRIM's cancel_tx
  (WalletTask::Cancel), releasing the outputs they had locked;
- annotates incoming payments / invoices we issued as Cancelled only
  (a late on-chain confirmation still wins);
- marks pending incoming requests Expired.

The activity feed and receipt now render "canceled" (this also fixes a
latent bug where a manually-cancelled tx still showed "pending").

Receipt de-duplication: the To/From name rows are shown only when the
counterparty has a real identity (petname or verified NIP-05); a bare
npub now appears once, in the "nostr" row, instead of twice.

38 lib tests pass (2 new for the expiry bucket logic).
2026-06-13 14:31:14 -04:00
2ro 695c3e6d4f Build 48: replace Tor with the Nym mixnet; remove arti
Route every relay and HTTP request (nostr relays, NIP-05, price) through
a local nym-socks5-client sidecar on 127.0.0.1:1080, so all traffic
egresses via the 5-hop Nym mixnet and nothing touches the clear net.

- Add src/nym/: SOCKS5 HTTP client (reqwest socks5h), NymWebSocketTransport
  for the nostr relay pool (tokio-socks dial + TLS/ws handshake over the
  mixnet), and a sidecar launcher that reuses or spawns nym-socks5-client.
- Swap the nostr-sdk transport off ArtiWebSocketTransport; route nip05.rs
  and price.rs off Tor; revert the clearnet username-lookup shortcut.
- Remove the embedded arti Tor client wholesale: the onion-service
  listener and send-to-onion path in the wallet, the legacy transport
  GUI tab, the Tor settings page, src/tor/, the webtunnel pluggable
  transport (Go build + submodule), and all arti crates from Cargo.toml.

The Grin node connection is unchanged (chain data, no payment metadata,
and never used Tor). The network requester the sidecar routes through is
configured via GOBLIN_NYM_PROVIDER / NETWORK_REQUESTER at deploy time.
2026-06-13 13:29:21 -04:00
2ro dda07dee0a Build 47: username lookup over clearnet, off Tor
The read-only NIP-05 username lookups (resolve + check_availability) carry no
secret and need no anonymity — routing them over Tor is what made choosing a
username slow. They now use a fast clearnet HTTPS GET (reqwest + rustls/ring,
so it still cross-compiles to Android), falling back to Tor only if clearnet is
blocked. The anonymity-critical paths (slatepack delivery, NIP-98-signed ops)
are untouched.
2026-06-13 12:40:36 -04:00
2ro 1ac1186319 Docs: link NIPs/kinds on first mention; gitignore screenshots/
Every first mention of a NIP or nostr kind in the README now links to
nips.nostr.com (NIPs) or nostrbook.dev (kinds). Add screenshots/ to .gitignore
(local gallery captures for the site).
2026-06-13 06:35:55 -04:00
2ro 4cad00079e Build 45: receipts, activity grouping, profiles + block
Rich transaction receipt screen (tap an activity row, or the new Receipt button
on a send's success screen): counterparty, time, note, amount, and a
Transaction-details card joining GRIM's local metadata with the nostr
npub/username — status (Complete vs Pending N/min-conf), To/From, network fee,
Mimblewimble, and the slate id. A local archive, like GRIM.

Group the Activity feed into Pending (unconfirmed) + per-day sections.

Contact profile screen (tap a peer or the receipt counterparty): who they are,
the history between you, a Pay shortcut, and Block — a nostr-level mute that
drops their incoming payments/requests (Contact.blocked, checked in ingest).

Refine success/denied copy ("to/from" by direction; "ask them to send you grin
instead"). Make the profile avatar display-only — no custom-picture upload.
2026-06-13 06:29:57 -04:00
2ro 143f4230c9 Build 44: fix recurring macOS release build
release.yml's `files:` upload globs still used ${{ inputs.tag }}, which is empty
on a `release: published` event, so the auto-built macOS universal zip never
matched its filename and didn't attach — the job went green while uploading
nothing. Use the inputs.tag || release.tag_name fallback for the globs too.

Also fix the CI build number: building from the public single-commit squash
makes `git rev-list b51a46b..HEAD` fail (the fork base isn't an ancestor), so
build.rs fell back to "Build dev". build.rs now honours a GOBLIN_BUILD env
override and release.yml passes GOBLIN_BUILD=${TAG#build}, so CI artifacts carry
the real build number.
2026-06-13 05:44:47 -04:00
2ro f714e4498a Build 43: real payment requests + incoming-requests opt-out
Wire GRIM's receiver-initiated invoice (Invoice1) over nostr so the Pay-tab
"Request" button actually asks someone for money: pick a contact, issue an
Invoice1, DM it; they get the existing approve-to-pay card. New
WalletTask::NostrRequest mirrors NostrSend (issue_invoice + RequestedByUs /
AwaitingI2, reusing send_payment_dm); the send flow gains a request mode
(Request from -> Confirm request -> Send request -> Requested) with no balance
guard, since requesting isn't spending.

Incoming requests are now opt-out (Settings -> Requests, on by default): when
off, an incoming Invoice1 is dropped and the preference is advertised in the
kind-0 profile (goblin_accepts_requests) so a requester sees "Could not
request" before sending. Adds a Cash App-style toggle switch widget.

Also enlarge the center Pay puck in the floating nav, and make the macOS
release build recur: release.yml now triggers on release publish (macOS only,
since Linux/Windows/Android/AppImage are built locally).
2026-06-13 05:08:51 -04:00
2ro 2e8829ef83 Build 42: block over-balance sends + roomier search icon
Guard against paying more than the spendable balance at every entry: the Pay
tab, the send-flow amount step, and the Review screen all render the amount in
red with "You don't have enough grin" and refuse to proceed, so a payment that
would only fail later at the node is stopped up front. On Android the phone
gives a short error buzz on the blocked tap — new VIBRATE bridge
(PlatformCallbacks::vibrate_error -> MainActivity.vibrateError); no-op on
desktop. Grim silently clamped the amount and showed nothing.

Also enlarge and vertically center the magnifier in the "Send to" search field
(an 18px muted label became a 22px centered glyph).
2026-06-13 02:51:29 -04:00
2ro 9d74d6fac5 Build 40: roomier Pay keypad spacing (Cash App-style)
The amount keypad rows were bunched tight (56px keys, 4px gaps) high on the
Pay screen, leaving a large empty area at the bottom. Now: taller (58) and
wider (pad 332, 14px column gaps) keys, bigger digits (30), and adaptive
row spacing that spreads the four rows toward the bottom when there's room
(the Pay tab) while staying compact where there isn't (the send flow).
Clamped so it never overflows or stretches absurdly.
2026-06-13 01:06:34 -04:00
2ro 4d5db923ea docs: de-duplicate the nostr_password comment in Wallet::open
Audit cleanup — collapse the garbled double comment to one line. No code change.
2026-06-12 23:10:44 -04:00
2ro f210180de6 Build 39: match Grim's focus handling on text fields (.focus(false))
Grim never lets a field default to constant focus. Its TextEdit defaults
to focus = true, which means an unfocused field calls request_focus()
EVERY frame. Grim's own usage always overrides this — every field is
.focus(false) (focus only on tap), and at most one gets .focus(first_draw)
to grab focus once when a screen opens (see grim wallets/modals/add.rs).

Goblin's converted fields skipped this, so all ~16 fields requested focus
each frame and fought each other on multi-field screens (wallet setup,
import, rotate). Set every field to .focus(false) to match Grim exactly:
fields now focus on tap and bring up the native keyboard, with no thrash.

The native IME path itself is already identical to Grim (verified: edit.rs
differs only by additive display builders; MainActivity onTextInput JNI
plumbing is byte-for-byte Grim; android/winit/eframe/egui/jni deps match).
2026-06-12 22:31:54 -04:00
2ro 1b5352da0d Build 38: use the native Android keyboard like Grim (revert the virtual-keyboard mistake)
Build 37 wrongly forced Grim's on-screen virtual keyboard on Android (.soft_keyboard())
after the emulator's broken IME misled me into thinking the native keyboard was dead.
That brought up a virtual keyboard AND double-typed. Reverted:

- Removed .soft_keyboard() from every field; they now use Grim's TextEdit defaults
  (no_soft_keyboard = is_android()) → the user's native keyboard on Android, exactly
  like Grim, no virtual keyboard, single input.
- Reverted edit.rs IMEAllowed/on_soft_input to Grim's exact behavior; kept only the
  additive display builders (hint_text/text_color/body) the Goblin design needs.
- Manifest: removed windowSoftInputMode and the keyboard/keyboardHidden/navigation
  configChanges flags I'd added (Grim has none) so input matches Grim; kept the
  uiMode/density flags + isFinishing() guard for the dark-mode crash fix.

The app's input path now diffs from Grim only by the additive display builders.
(The Android emulator's IME is aborted regardless — a known emulator quirk — so the
native keyboard is validated on a real device, where Grim works.)
2026-06-12 22:14:49 -04:00
2ro 2cc023b905 Build 37: fix Android — typing, install-alongside-Grim, status bar, edge padding
- Typing (critical): the Goblin UI used plain egui::TextEdit, which never
  received input because Grim's native-keyboard path only feeds Grim's own
  TextEdit widget. The native IME is also aborted by the windowing system on
  this app. Fix: every Goblin field now uses Grim's TextEdit, opted into
  Grim's own on-screen keyboard on Android via a new .soft_keyboard() builder
  (no native-IME dependency). Additive TextEdit options (hint_text/text_color/
  body/soft_keyboard) leave Grim's defaults and existing call sites unchanged.
- Install alongside Grim: FileProvider authority mw.gri.android.fileprovider →
  st.goblin.wallet.fileprovider (Android rejects duplicate authorities).
- Status bar: app draws edge-to-edge, so dark icons vanished on the dark theme.
  New theme-aware JNI call sets light/dark status-bar icons per theme.
- Edge padding: centered_column now keeps an 18dp side gutter on phones instead
  of running content flush to the screen edge.

Validated on the API-36 emulator: typed into fields, padding gutter present.
2026-06-12 21:47:54 -04:00
2ro 993a438e18 Build 36: friendlier ツ on the Pay puck via a Gamja Flower subset
The center Pay puck rendered ツ through the Noto SC fallback (Geist has no
katakana) — a stiff, geometric glyph. Embed a 1.2 KB subset of Gamja Flower
(OFL) containing only ツ and use it solely at that one widget; its ツ is the
cute winking-smiley shape. Every other ツ (balances, title, activity) is
untouched.
2026-06-12 18:15:16 -04:00
2ro b1c3c07dac Build 35: lead Tor bootstrap with the maintained bridge solo
The default webtunnel list bundles wt.gri.mw with public bridges that rot;
bundling them means arti can fixate on a dead/zombie bridge (front up, tunnel
dead) and burn the whole 120s bootstrap timeout — observed live on Android as
'stuck at 15%, had to reset bootstrapping too many times' (48 attempts on a
zombie, 1 on the good bridge). Now the first attempt uses the maintained
default alone; the public bridges stay as fallback in pairs only if it's
unreachable. Verified on the API-36 emulator: Tor reaches 100% over wt.gri.mw,
zero attempts on the dead bridge.
2026-06-12 17:44:04 -04:00
2ro 878f7728eb Build 34: harden the nostr ingest path (audit Medium items)
- N-F1: commit the wrap/rumor/slate dedup markers immediately after the
  durable receive/finalize, before the reply+sync tail, so a crash there
  can't re-trigger the action on catch-up. (grin + decide() already
  backstopped it; this closes the window cleanly.)
- N-F3: prune the processed-dedup store hourly in the heartbeat, not only
  at startup — a long-lived session could otherwise grow it unbounded
  under fresh-keypair spam since the 30-day TTL never re-applied.
- N-F2: kept Created/SendFailed in the finalize allow-set (removing them
  would strand a real send whose S1 reached the peer before we persisted
  AwaitingS2) and documented why it is not a forgery vector; added a test.
- Update replay_check e2e: a same-pubkey second register is now blocked by
  the name-change cooldown (fires before the one-name rule); accept either.

Validated: 35 lib tests + live nip17_slatepack_roundtrip + replay_check green.
2026-06-12 12:48:34 -04:00
2ro 60e4e8b5a9 Build 33: security audit fixes — remove the clearnet bridge probe, cap untrusted responses
- SECURITY (High): drop sort_bridges_by_reachability / bridge_probe_addr. The
  Build 30 probe did clearnet DNS + direct TCP to bridge endpoints outside Tor
  on every startup, deanonymizing bridge users. The consensus-cache keep + 120s
  timeout + pre-warm remain and are the real fix for slow first connect.
- SECURITY (Low): cap HTTP response bodies from the untrusted goblin.st server
  at 2 MiB, streamed so a lying/absent Content-Length can't OOM the wallet.
2026-06-12 12:18:57 -04:00
2ro b9ce88e996 Build 32: federate identity + sharpen identity chrome
- Accept any NIP-05 domain in the send flow (user@domain resolves and pays;
  foreign handles display with their domain and hit the unverified-key gate)
- Share and scan nprofile (npub + relay hints) so a recipient is reachable
  with no registry/indexer lookup; hints ride the DM send path
- Receive QR, copy buttons and settings row now emit nostr ID (nprofile)
- Sidebar identity chip truncates long handles on one line (was wrapping)
- Crisper small goblin mark via a 2x raster at chip sizes
- Map the server's name-change cooldown to friendly copy in claim/release
2026-06-12 11:54:35 -04:00
2ro 60414e9477 Build 31: survive Android configuration changes — the activity-recreate path killed the whole process (crash on open with forced dark mode); widen configChanges, gate process teardown on isFinishing 2026-06-12 11:02:54 -04:00
2ro 7eefb54075 Build 30: Tor up in seconds — probe bridges, keep consensus cache, pre-warm at start; borderless window frame, vector sidebar mark, Wayland app id, v2 icon set 2026-06-12 02:51:00 -04:00
2ro cda1be992f Build 29: standardize the AI credit line in the README 2026-06-12 02:00:01 -04:00
2ro 9257f82dcf Build 28: drop internal dev docs and upstream Forgejo CI from the repo 2026-06-12 01:47:37 -04:00
2ro 7f09598298 Build 27: let the Android app build without upstream's private maven mirror 2026-06-12 01:23:29 -04:00
2ro 03741f7839 Build 26: replace upstream release CI with native-runner workflow for GitHub 2026-06-12 01:23:29 -04:00
Claude 78f629d8d3 Build 25: default to a steadier instant node + add the Goblin node
main.gri.mw has intermittent issues, so the default external ("instant") node
is now grincoin.org — both the onboarding first-connect and the head of the
external-node list. Adds the Goblin-run node https://main.us-ea.st as a default
option. (Relays already default to ours, nrelay.us-ea.st, plus relay.damus.io
and nos.lol — no change needed.)
2026-06-11 23:48:31 -04:00
Claude 68d72fa853 Build 24: equal-size buttons in the release-username confirm gate
"Keep it" used big_action_on_card (56px / 17px) while "Release it" used
big_action_on_card_ink (44px / 15px), so the two confirm buttons were
visibly different sizes. Both now render with big_action_on_card_ink at the
intended 44px (matching the layout's scope), differing only in text color.
2026-06-11 23:41:38 -04:00
Claude 95403516d5 Build 23: README rewrite + banner — describe Goblin, not Grim
Replace the inherited Grim README with one that reflects what Goblin actually
is: a private, Cash App-style GRIN wallet that pays @usernames via NIP-17
gift-wrapped DMs over Tor, with in-app nostr identity and the goblin.st
identity service. Adds the Goblin banner, a payment-flow diagram, correct
build instructions (binary is `goblin`), and keeps the Claude credit.
2026-06-11 23:26:11 -04:00
Claude f2402eb24d Build 22: security hardening follow-ups
From the audit's deferred P2 list:
- Global gift-wrap decrypt ceiling (~120/min across all senders) so
  fresh-keypair spam can't force unbounded NIP-44 decrypts. The per-sender
  limit only applied after the decrypt revealed the sender; this caps total
  decrypt work up front.
- NostrStore reads tolerate a poisoned lock (unwrap_or_else into_inner) so a
  single panic can't cascade into taking down all nostr storage for the session.
- Avatar upload/delete reuse the service's keys directly instead of
  round-tripping the secret through a plaintext nsec String.

34 lib tests green.
2026-06-11 23:23:26 -04:00
Claude 413746dde3 Build 21: harden the client — NIP-05 gate, hostname validation, avatar limits
From a security audit of our own nostr/identity code (no P0/P1 found; these
close the P2 hardening gaps):
- NIP-05: only goblin.st identities skip the "pay an unverified key?" gate.
  A third-party domain's well-known could point at any key, so those now route
  through the same confirm gate as a bare npub.
- NIP-05: validate the domain as a bare hostname before building the
  well-known URL — closes a path/host-smuggling (SSRF-over-Tor) vector.
- Avatars: decode server-fed bytes under explicit image Limits (<=1024 px,
  8 MiB) so a hostile or breached avatar host can't exhaust memory on the
  texture path.

34 lib tests green (incl. new hostname-rejection cases).
2026-06-11 22:55:09 -04:00
Claude c3b23dc1a7 Build 20: README credit, onboarding copy, centered balance hero
- README: credit Claude (Anthropic) for Goblin's development at the bottom,
  in place of per-commit co-authorship.
- Onboarding: replace the contradictory "Pay people, not addresses" card with
  "Send like a message" and reword the body in plain language (no more
  "plumbing"/"ciphertext"). The identity step now says you can rotate your key
  anytime to maintain your privacy.
- Home: center the balance hero so it lines up with the Pay amount and the
  empty-state below it.
2026-06-11 22:55:09 -04:00
Claude 15c19303ff Build 19: open-wallet shortcuts, Pay QR scan, configurable pairing, hide yellow
- Lower-left sidebar cards are now individual shortcuts: tapping the
  identity chip opens identity settings, tapping the node card jumps
  straight to the Node menu (was: both opened generic settings).
- Pay screen gains a scan-to-pay QR puck top-right that opens the camera
  and prefills the recipient while keeping the typed amount (reuses the
  Home scan + SendFlow::request_scan/prefill_amount path).
- Replace the USD-only "fiat preview" with a configurable "Pairing":
  Off / USD / EUR / GBP / JPY / CNY / Bitcoin / Sats (default USD). price.rs
  now fetches GRIN against any vs_currency (sats price vs btc, ×1e8) and
  caches per code; a Settings → Pairing sub-page picks it; the Pay, send,
  and balance previews all route through one pairing_preview() helper.
- Hide the Yellow theme from the picker (cycle is Dark ↔ Light now); the
  ThemeKind::Yellow tokens stay defined — it's beta, not removed.

Verified live on :2 (Default wallet): node card → Node menu; identity chip
→ identity settings; QR puck renders top-right of Pay; Pairing → Sats shows
"≈ 1,912 sats" for 50ツ over a live BTC rate, resets to USD; theme cycles
Dark↔Light, never Yellow. 34 lib tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:22:39 -04:00
Claude 0438d70cae Build 18: move the integrated node off the wallet list into the cog
- Remove the Node status chip from the wallet-list header; the integrated
  node now lives in the cog's settings as a status + Enable/Autorun section,
  with a "Node settings" button into the full stats/mining/tuning panel.
- Stop the desktop two-column rail from auto-docking the node beside the
  settings screen, so the node has a single home in the cog at every width
  (the full panel opens only on demand). Fixes the wide-window double-exposure.
- Prove NIP-17 payments transit the top public relays: parameterize the
  nostr e2e roundtrip and add damus.io + nos.lol proofs (3/3 green).
- Add DEVELOPING.md documenting how we iterate and test (Xwayland :2 recipe,
  Build N cadence, gui-sweep, e2e tests, live infra) — no secrets.

Verified live on :2: chip gone from the list; cog shows the node once
(single column, no left rail); "Node settings" opens the full panel on
demand. 34 lib tests green; nip17 roundtrip green over nrelay + damus + nos.lol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:21:13 -04:00
Claude 86f042facb Build 17: elastic recipient search w/ nostr verification, numpad, copy
Recipient picker (send.rs) is now type-ahead instead of button-driven:
- as you type, instant local-contact matches + a debounced network
  lookup surface as tappable cards (no more "Find recipient" button)
- a name or @handle is treated as a goblin username (nip05 resolve) and
  shown as a tappable card — tap the right identity to select it
- a pasted npub/hex is verified over nostr: fetch its kind-0 profile and
  badge the card "✓ on nostr"; a valid key with no profile shows "no
  profile" and, when tapped, asks "Pay an unverified key?" before
  continuing (owner choice — never hard-blocked)

NostrService::fetch_profile_blocking (client.rs) — one-shot kind-0 fetch
over the connected relay pool (nrelay + damus + nos.lol), so arbitrary
npubs resolve across the public network. data::search_contacts for the
local half.

Numpad (widgets.rs): centered, evenly-columned grid — the trailing
per-key space was drifting it off-center under the amount; hovered keys
now highlight in accent.

Onboarding identity step: added a plain-spoken line that you can swap in
a fresh key any time to unlink from your old identity.

34 lib tests green. Verified live on :2: numpad centers (92/198/300),
fiatjaf npub → "✓ on nostr", an unprofiled key → "no profile" → confirm
gate → Pay anyway → Review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:59:01 -04:00
Claude 6dbd0f8e9d Build 16: fix yellow-theme muted-label contrast (WCAG AA)
The yellow theme's muted on-background tier (text_mute #6B6A63) rendered
the eyebrow labels (WALLET/PRIVACY/BALANCE/ACTIVITY…) and the "Moving
devices? Back up BOTH…" helper at only 3.85:1 on the #FFD60A background —
below the 4.5:1 AA floor (sweep P2). Darkened to #55534A (5.5:1,
measured), still the faintest of the three on-bg tiers so the hierarchy
holds. Dark/light themes unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:06:09 -04:00
Claude c72cda3039 Build 15: clean the returning-user wallet list of GRIM node chrome
The wallet-list screen (wallets exist, none open) still wore GRIM's
node-first chrome: a yellow INTEGRATED NODE / WALLETS bar and a forced-
open left node column with ENABLE NODE / Autorun / stat tabs. That
fights the payments-first philosophy the open-wallet surface set — the
node is plumbing, chosen in onboarding and managed in settings, not a
console you stare at to reach your wallets.

Now the list is full-bleed dark like the rest of the goblin surface:
- the node panel no longer force-opens on this screen (content.rs);
  it's demoted to a tappable status chip that opens the SAME panel,
  every Enable/Autorun/mining/stat feature intact — just opt-in
- the yellow title bar is suppressed here (wallet_list_screen()), its
  GEAR (app settings) and node access rehomed into a slim goblin header
  (node chip left, gear right) above the GOBLIN logo + wallet rows + "+"

Verified desktop + mobile + panel open/close; unlock still lands on the
unchanged open-wallet surface. 34 lib tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:26:50 -04:00
Claude d53345ffdd Build 14: hide scrollbars on the goblin surface
The yellow theme exposed the egui scrollbar track as a stray dark line
down the Settings column (sweep P2). The goblin surface is touch-styled,
so its content scroll areas now hide the bar entirely (AlwaysHidden),
matching the rest of the app and removing the artifact on every theme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:41:19 -04:00
Claude b1b9bd61af Build 13: hosted profile pictures, username release, claim/release UX
Identity overhaul (server changes deployed to goblin.st separately):

Avatars
- profile pictures hosted on goblin.st, tied to the username: tap the
  settings avatar → native image picker → 256px PNG uploaded over Tor
- letter avatars now use (background, ink) color pairs (8) keyed off the
  npub, and render the first ALPHANUMERIC char — never the '@'
- custom pictures shown everywhere self/contacts appear: settings card,
  home header, sidebar chip, peers strip, activity rows, send recipient
- AvatarTextures: disk cache (~/.goblin/cache/avatars) + background Tor
  fetch + egui textures loaded on the UI thread; a network/Tor failure
  is never cached as "no avatar" (would have stuck for 6h)
- nostr/avatar.rs mirrors the server's sniff→limits→orientation→crop→
  256→re-encode-PNG pipeline so uploads are small and previews instant

Username lifecycle
- rotating the key now RELEASES the username (and deletes its avatar
  server-side) instead of transferring; rotation aborts if release fails
- claim panel is one Claim button (checks then registers); registered
  state shows "Registered <name>" + a Release action behind an
  are-you-sure gate ("up for grabs the moment it's free")
- released names are immediately re-claimable (quarantine removed)

Other
- Tor::http_request_bytes: binary bodies + status code, for upload and
  avatar download (string http_request kept as a wrapper)
- settings reordered Identity-first, then Wallet
- sidebar node card is 3 lines: status / block height / host
- profile card shows the full npub when it fits, else head…tail

34 lib tests green. Live-verified on goblin.st: upload→serve (image/png,
nosniff, immutable)→5/day limit (6th 429)→release purges avatar; a real
picture for @fartmuncher22 fetched over Tor and rendered across surfaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:22:41 -04:00
Claude a12f894dff Build 12: failed username checks no longer read as 'Taken'
check_availability returns Unknown when the Tor request dies, and the
claim panels collapsed everything but Available into 'Taken' — a free
name looked taken whenever a circuit flaked. The full enum now reaches
both panels: Unknown reads 'Couldn't check — connection hiccup. Try
again.' in neutral gray, and Reserved/Invalid/Quarantined get their own
copy. Tor::http_request also backs off up to ~15s across 5 attempts:
fresh circuits fail while arti refreshes its directory consensus over
the bridge, and 3 tries in 4.5s couldn't ride that out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:03:54 -04:00
Claude 0c60368280 Build 11: node card tells 'can't reach node' apart from syncing
A flaky external node read as 'Syncing…' forever — the card now goes
red with 'Can't reach node' on wallet sync errors (wallet.sync_error())
and recovers on the next good cycle. Validated against a dead node and
back on a live one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:50:20 -04:00
Claude 9d36562bab Build 10: width-gate the Pay numpad, mark the settings scroll edge
The numpad was platform-gated while the mobile shell is width-gated, so
narrow desktop windows showed neither numpad nor a visible input (sweep
P2). Settings rows clipping under the pinned profile card now meet a
hairline instead of slicing glyphs on an invisible edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:52:50 -04:00
Claude d8cf06b577 Build 9: QR scan, camera fixes, first-run onboarding, sweep fixes
Camera/QR (validated live with a v4l2loopback virtual camera):
- decode non-MJPEG frames on Linux (raw YUYV was undecodable: eternal
  spinner), enumerate devices off the UI thread without unwrap, open
  cameras by their real device index (list position broke whenever
  /dev/video0 was absent), show "No camera found" after 5s
- scan-to-pay: QR button in the send recipient search row and the
  mobile home header; in-surface camera panel feeding recipient
  resolution; only text payloads accepted (seeds/slatepacks refused)
- receive QR was unscannable: full cells (0.5px gaps fragmented finder
  patterns at 4.5px cells), always ink-on-white plate (inverted codes
  fail many scanners), center mark 26% -> 19% (zbar chokes above);
  rqrr+zbar both decode the live card now; unit tests cover the exact
  widget geometry

First-run onboarding (replaces the stock empty state only; the wallet
list, add-wallet modal and GRIM creation flow stay for later wallets):
- intro -> node choice (Private integrated / Instant external with
  URL) -> create or restore (wraps MnemonicSetup; word grid, paste,
  SeedQR scan) -> optional @username claim with prominent skip
- fonts bind at creation context so frame one can use Geist families

Sweep fixes (22 confirmed findings, the simple ones):
- yellow theme: active sidebar nav was dark-on-dark (P1)
- window frame ring painted with theme bg: transparent clear color
  rendered as a black band without a compositor (P2)
- import/rotate identity inputs get visible field wells (P2)
- receive buttons: verb labels + transient "Copied" feedback
- review hero card fills the column; fee row copy; pay-tab stale and
  duplicated hints; unlock modal copy; node card subtitle truncation
- build number stays current (.git/logs/HEAD rerun trigger)

Backup restore: import accepts the export-time password and re-encrypts
under the current wallet password; cross-device restores work.

31 lib tests green. Mainnet-validated: both flows restored real
wallets; 0.1 grin sent B->A through NIP-17 over Tor with async
open/close ping-pong; tx 71fbfce4f591 posted on-chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:07:53 -04:00
Claude 908df117e6 Build 8: stability fixes, Cash App-style shell, settings, key rotation
Stability (found via GUI testing):
- tor: create arti runtime on a clean thread; lazy TOR_STATE init panicked
  inside tokio contexts and poisoned the whole Tor/nostr stack
- store: open rkv SafeMode envs with capacity headroom; reopening at
  exactly DEFAULT_MAX_DBS crashed every wallet restart (DbsFull)
- goblin ui: centered_column hands children a full-height rect; ScrollAreas
  inside clipped everything below the first widget
- build: webtunnel client was silently embedded as 0 bytes without Go;
  warn at build, extract with create_dir_all + exec bit at runtime
- price: CoinGecko requires a User-Agent (403 otherwise); add retry-once
  backoff and a parse-failure diagnostic
- tor: retry http_request up to 3x on fresh isolated circuits

UX overhaul (per owner direction + Cash App references):
- floating icon-only 3-tab pill: Wallet / center accent ツ Pay puck /
  Activity (requests badge kept); Me opens via header avatar
- Pay tab: amount-first surface (numpad on mobile, typed on desktop) with
  Request and Pay; Pay carries the amount straight to Review
- Request flips Receive into "Requesting Nツ" state with a clear chip
- full-bleed goblin surface: GRIM title panel and network column hidden
  while a wallet is open; node status card lives in the sidebar above the
  profile chip; Lock wallet row added (Settings -> Wallet)
- goblin branding: titlebar, wallet-list logo + GOBLIN, new mark assets
  (white master, theme-tinted) in wordmark, QR center, wallet list
- build-based versioning: Build N = commits since the GRIM fork base,
  emitted by build.rs; About leads with it, Third party credits GRIM,
  grin node, nostr-sdk, arti, egui and the implemented NIPs

Accessibility & settings:
- surface_text{,_dim,_mute} tokens: yellow theme has dark cards on a
  bright bg; all on-surface text now readable in every theme (incl. QR)
- settings rows clickable across the whole row; profile card fills width;
  density option removed (comfy fixed)
- editable Node connections (integrated/external, add/remove) and Relays
  (add/remove + live service restart); NIPs explainer page with goblin.st
  context; third-party rows link to upstream projects
- standardized npub truncation (head 12 ... tail 6) shown in profile

Identity (owner decision: drop NIP-06 seed binding):
- random standalone nsec (Keys::generate); seed proves nothing about the
  identity and cannot resurrect it; legacy Derived identities still unlock
- key rotation: double warning (pending payments disrupted), typed RESET +
  wallet password, fresh random key, username moved atomically via the
  name server transfer endpoint; aborts cleanly if the move fails
- encrypted identity backup export (NIP-49 ncryptsec JSON, includes
  username + history) and import accepting nsec or backup JSON
- nip05d: POST /api/v1/transfer (NIP-98 by current owner, atomic
  owner-guarded swap, one-name-per-pubkey enforced) + SQL invariant tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:04:40 -04:00
Claude 906fee9c71 P8: desktop sidebar (shell B), theme + density pickers
- Wide desktop (≥720px) now renders a left sidebar nav (Wallet/Activity/Send/
  Receive/Settings) with a profile card instead of the bottom tab bar; narrow
  screens keep the bottom tabs. Both drive the same Tab state and screens.
- Settings gains an Appearance group: tap to cycle theme (Dark/Light/Yellow,
  re-applies egui visuals live) and density (Comfy/Regular/Compact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:33:32 -04:00
Claude 32696438d3 Make Privacy settings interactive (accept policy + fiat toggle)
Tap to cycle the incoming-payment accept policy (Anyone/Contacts/Ask) and to
toggle the fiat USD preview, completing the user-control story for settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:30:17 -04:00
Claude aa9847bb41 Fix functional bugs found in UI audit
Send flow:
- "Sent" success now gates on the real dispatch result (NostrService send
  phase Working/Sent/Failed) instead of send_creating, which cleared before
  the DM left. Added a Failed stage with Try again / Close so a failed send
  is no longer shown as success.
- NIP-05 handle resolution moved off the UI thread (was .join()-blocking the
  render thread for the Tor timeout); now a worker thread writes into a polled
  slot with an inline "Looking up…" spinner.
- Desktop amount entry no longer steals keystrokes from the Note field (guards
  on the note TextEdit focus).
- Review screen now shows a network-fee line.

Requests / identity / archive:
- Approve is now double-tap safe (per-session approving set + disabled button),
  closing a double-pay window.
- "Backup nostr key" copied the public npub; split into "Copy npub (public)"
  and "Back up secret key (nsec)" which copies the real secret.
- Added inline username claim (availability check + NIP-98 register over Tor,
  off-thread) for anonymous users; persists nip05 + republishes identity.
- Added archive Export / Wipe history controls.
- Receive "Slatepack" button now copies the grin1 address; "Scan" tab relabeled
  "Receive" (no scanner was wired).
- Goblin view resets on wallet switch so a half-filled send can't leak across.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:26:54 -04:00
Claude ce1c071f3c Security hardening from adversarial audit
Wallet:
- identity.json (NIP-49 ncryptsec) now written 0600 in a 0700 dir so a local
  user can't grind the wallet password offline (+ regression test).
- Wallet password held as a ZeroingString through init_nostr so it's scrubbed
  on drop instead of lingering in a plain String for the session.
- Replaced 4 .unwrap() on re-read tx_meta with graceful guards (archive wipe
  mid-send could otherwise panic the nostr/task thread).
- Tor::http_request/post: bind the client once via let-else and propagate TLS
  builder errors, fixing a TOCTOU unwrap panic on concurrent Tor restart.

goblin-nip05d server (redeployed to goblin.st, verified live):
- One-name-per-pubkey now enforced by a partial UNIQUE index (closes the
  check-then-insert race); INSERT rows-affected==0 returns 409 not a false 201.
- NIP-98 replay protection: one-time auth event-id enforcement within the
  freshness window; tightened forward skew to +5s.
- Rate-limited the unauthenticated GET endpoints; SQLite in WAL mode.
- Verified live: replay rejected, second name for a pubkey blocked.

Audit verdict: fund-safety invariants (never auto-pay Invoice1; S2/I2
finalization bound to counterparty npub) and Tor-from-day-one all hold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:13:15 -04:00
Claude 87efc8bb2d Add live e2e tests for the nostr relay and NIP-05 server
tests/nostr_e2e.rs (run with --ignored):
- nip17_slatepack_roundtrip: Alice gift-wraps a slatepack payment DM with a
  subject to Bob over wss://nrelay.us-ea.st; Bob unwraps, the seal-author ==
  sender invariant holds, the slatepack and subject extract intact.
- nip05_registration_roundtrip: registers a fresh name on goblin.st with a
  real NIP-98 signature, resolves it back to the right pubkey, releases it.

Both pass against the live deployed infrastructure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:54:37 -04:00
Claude 8a6d442544 Goblin UI: Cash App-style wallet surface (P4-P5)
New src/gui/views/goblin/ module rendered as the primary surface for an open
wallet: bottom tab bar (Wallet/Activity/Scan/Me), balance hero in ツ, Send/
Receive, recent peers, activity feed (wallet txs joined with nostr metadata
by slate id), pending payment requests with approve/decline, receive screen
with nostr-handle QR, settings/Me tab. Full send flow (recipient resolve via
npub/NIP-05 over Tor -> numpad amount -> review -> hold-to-send -> success)
dispatching WalletTask::NostrSend. Widgets library, fiat preview over Tor.
Fixed a font-binding panic: weight families are referenced only at widget
call sites, not in default text styles (set_fonts applies a pass later).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:50:52 -04:00
Claude 1848d0c796 Goblin P0-P3 backend: brand reskin, theme tokens, nostr payment subsystem
Infrastructure (P0): deployed nostr-rs-relay (wss://nrelay.us-ea.st) and the
goblin-nip05d NIP-05 service (goblin.st) on us-ea.st with TLS + DNS.

Brand & theme (P1): Goblin name/icon/data-dir (.goblin); three-theme token
system (light/dark/yellow) in gui/theme.rs with colors.rs remapped as a shim;
Geist + Geist Mono fonts; AppConfig theme/density/last_wallet_id.

Nostr subsystem (P2-P3): src/nostr/ with NIP-06 identity (seed-derived,
NIP-49 encrypted), per-wallet rkv archive, guarded ingest policy (never
auto-pays Invoice1; binds replies to the stored counterparty npub), NIP-17
send/receive pipeline, NIP-05 client. Relay traffic routed over the embedded
arti Tor client via a custom WebSocketTransport. Wired into Wallet lifecycle
and the task handler. 26 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:35:12 -04:00
ardocrat b51a46b943 build: update node and wallet to latest versions 2026-06-04 18:06:32 +03:00
ardocrat 3d1a721f29 node: optimize iterator 2026-05-31 15:57:22 +03:00
ardocrat 176df6f93e wallet: fix tx repost, delete txs with 0 amount, trim message to parse 2026-05-25 16:44:51 +03:00
ardocrat f7287bd9ad tor: create runtime only once 2026-05-25 16:32:13 +03:00
ardocrat 4c4b6cd5dc ui: better check for wallet data emptiness 2026-05-23 18:21:04 +03:00
ardocrat 4aeda9c9dc build: v0.3.6, format code 2026-05-21 00:56:28 +03:00
ardocrat a4eadebef2 wallet: handle iter result 2026-05-21 00:54:02 +03:00
ardocrat 15d1aa1a21 build: git format hook 2026-05-21 00:04:22 +03:00
ardocrat 242a3b9434 node: optimize lmdb iterator, pibd peers fix to use .zip fallback 2026-05-20 20:15:46 +03:00
ardocrat edc1a09b2c tor: remove delay after connection, immediately show service as started after bootstrap, remove unused features 2026-05-20 18:14:06 +03:00
ardocrat f31953f455 fix: recreate send/receive/message modals content on open 2026-05-20 17:43:46 +03:00
ardocrat d573ddedca tor: update to 0.42, add arti to logger 2026-05-18 22:53:11 +03:00
ardocrat 3be6925ff8 node: store blocked peers at memory, rust edition 2021 2026-05-14 23:01:37 +03:00
ardocrat c7abd9cbfa wallet: bigger scan window, include last height into scan batch 2026-05-14 21:40:57 +03:00
ardocrat 512d216fee log: filter, debug by default 2026-05-14 21:39:48 +03:00
ardocrat eaefc58c5a wallet: fix scan 2026-05-13 17:38:07 +03:00
ardocrat 2519e68dd5 ci: build checkout submodules 2026-05-13 17:37:45 +03:00
ardocrat 73c0884f95 ci: macos runner 2026-05-08 00:10:14 +03:00
ardocrat f7b2150228 wallet: parse slatepack message at background thread 2026-05-04 14:10:52 +03:00
ardocrat 03924b5300 build: remove unused cpp flags for android 2026-05-04 01:31:35 +03:00
ardocrat a479189135 tx: show message input after copy/share if finalization is needed 2026-05-03 23:34:45 +03:00
ardocrat e691a7b02d ci: fix changelog, update wix upgradecode on every build 2026-05-03 23:05:26 +03:00
ardocrat f2b79cd70d build: add rustfmt hook and config 2026-05-03 10:05:03 +03:00
ardocrat f20d1ee2c2 node: use git hash for user agent 2026-05-02 23:34:42 +03:00
ardocrat 534c4cc86a node: update user-agent, include last fixes for peers from PRs 2026-05-01 12:50:50 +03:00
ardocrat 558ac034b2 txs: fix save with new lmdb, sort to show new on top 2026-05-01 11:42:35 +03:00
ardocrat 13bf8e830c node: reset data from settings 2026-05-01 02:18:47 +03:00
ardocrat 57f319edfc android: include application mime type 2026-04-30 20:36:42 +03:00
ardocrat 748aebffb6 ci: release runner for version and forgejo release 2026-04-30 20:16:12 +03:00
ardocrat b32085a423 node + wallet: update lmdb 2026-04-30 18:27:27 +03:00
ardocrat a9c65546e3 node: update default dns seeds, setup seeds for testnet on launch 2026-04-30 14:14:51 +03:00
ardocrat b94241b82a wallet: select external connection by default on creation if integrated node is not running 2026-04-30 13:53:44 +03:00
ardocrat 8a1a69b739 node: update seeds 2026-04-24 01:14:16 +03:00
ardocrat 01d17e25ee tor: update webtunnel list 2026-04-24 00:12:22 +03:00
ardocrat cab38097fa build: v0.3.5 2026-04-21 13:13:28 +03:00
ardocrat 0026fc3717 build: fix android no_mangle attributes for rust 2024 2026-04-11 23:12:56 +03:00
ardocrat 0fd04f14a4 wallet: save last scanned block info to save progress on scan interruption 2026-04-11 22:52:43 +03:00
ardocrat 3338f51de5 tor: update to arti 0.41 2026-04-11 00:33:41 +03:00
ardocrat 0fa8963bd2 fix: wallet txs selection, wait starting tor service on send 2026-04-10 15:50:58 +03:00
ardocrat 70bba5d7ce pull_to_refresh: refresh when dragged far enough without release 2026-04-10 15:38:43 +03:00
ardocrat 0bb43e1e5d ui: show loader when fee is calculating 2026-04-10 15:18:23 +03:00
ardocrat fd52757549 build: version 0.3.4 2026-04-10 15:09:41 +03:00
ardocrat 6835bb1909 fix: do not send over tor when service not launched 2026-04-10 00:28:27 +03:00
ardocrat 31bc74529c build: update grin node 2026-04-09 20:56:45 +03:00
ardocrat 8d6943975b gui: glow renderer by default 2026-04-09 02:44:06 +03:00
ardocrat 4c5d8abe7b wix: update uuid 2026-04-09 02:44:01 +03:00
ardocrat 4dc42bce4a tor: fix multiline bridge connection 2026-03-30 01:37:36 +03:00
ardocrat e2d5d92f18 build: version 0.3.3 2026-03-30 01:36:45 +03:00
ardocrat b001eb4712 node: update to last git version (fix pibd stuck) 2026-03-29 21:18:36 +03:00
ardocrat f14bd902ea build: update win package guid 2026-03-24 14:52:20 +03:00
ardocrat 33ab11933a build: update wallet branch 2026-03-24 14:51:12 +03:00
ardocrat 6b05a2177e log: info level into file, crash report for android 2026-03-24 13:18:04 +03:00
ardocrat 7bbe637414 ui: do not show username at ext conn settings 2026-03-24 02:32:53 +03:00
ardocrat 9b6252de3a tor: fix connection with multiple bridges 2026-03-24 02:13:08 +03:00
ardocrat 26debcf51c ui: camera paddings, focus on password at wallet creation modal 2026-03-24 02:06:00 +03:00
ardocrat 497b967fd0 node: scan and share connection with qr code 2026-03-23 04:46:29 +03:00
ardocrat 05e18cf6c4 fix: show tx modal after message parse, cancel tx when slate not found 2026-03-23 02:49:23 +03:00
ardocrat 6e50b2b38a ui: make list items clickable, ability to delete tx 2026-03-23 01:21:09 +03:00
ardocrat 9bc96de398 ci: optimize release upload
- separate job telegram upload to avoid forgejo release upload repeat if failed
- upload artifacts to forgejo from another runner

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/55
2026-03-22 22:14:04 +00:00
ardocrat 5a525c50e1 img: update cover 2026-03-19 10:37:45 +03:00
ardocrat ba0af0968d tor: multiline bridges input, optimize tor connection check, add multiple default webtunnel bridges, fix tx cancel on finalization error 2026-03-18 15:44:32 +03:00
ardocrat a0947aa47c ci: fix pre-release check 2026-03-15 21:18:42 +00:00
ardocrat 06c6b8b4f5 android: fix text input on some devices 2026-03-15 23:26:57 +03:00
ardocrat b19335d0bc build: version 0.3.2 2026-03-15 23:25:46 +03:00
ardocrat 40eb30fb75 macos: fix version 2026-03-15 23:25:42 +03:00
ardocrat 8223e52570 build: version script 2026-03-15 23:25:08 +03:00
ardocrat 875bd11bdb ci: macos universal release name 2026-03-10 02:02:15 +03:00
ardocrat 19e4cb664d ci: fix pre-release check 2026-03-10 01:58:28 +03:00
ardocrat 18bc327a99 build: update msi and android version 2026-03-10 01:43:15 +03:00
ardocrat 88e2fb0715 node: update 5.4.0 release 2026-03-10 01:09:53 +03:00
ardocrat feb38dc7cf ui: show scan and wallet actions before getting data from node 2026-03-10 00:49:18 +03:00
ardocrat 28ecb5b1f4 fix: camera image square crop and animate qr code scanning progress 2026-03-10 00:39:42 +03:00
ardocrat 024a9d0098 ui: make qr codes background lighter for better scanning 2026-03-10 00:06:57 +03:00
ardocrat 59cf46e1cb feat: open modal to send amount on address scan 2026-03-09 21:33:11 +03:00
ardocrat 22255e0f2a fix: close wallet panels when settings are open 2026-03-09 20:55:11 +03:00
ardocrat 7fdb8d272b fix: hide account list panel on wallet change, do not store account list at content 2026-03-09 20:48:00 +03:00
ardocrat d043562058 build: bump version 2026-03-09 20:10:01 +03:00
ardocrat 096788c899 android: open only text files 2026-03-09 11:49:07 +03:00
ardocrat ba914903eb ci: fix git tag check 2026-03-08 23:45:25 +03:00
ardocrat 162c5f88eb fix: android build for status text 2026-03-08 22:53:24 +03:00
ardocrat ae0ff12935 feat: check app updates
Check update using API endpoint: https://code.gri.mw/api/v1/repos/gui/grim/releases/latest

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/54
2026-03-08 19:28:28 +00:00
ardocrat af203b8f9b ui: update i18n lib 2026-03-06 23:51:44 +03:00
ardocrat 1bd57cd88d fix: check transport settings change to restart services 2026-03-06 23:09:17 +03:00
ardocrat 8eea776111 ui: optimize paddings for mobile 2026-03-06 22:26:58 +03:00
ardocrat f5f6141881 ui: txs limit and sort, wallet deletion from the list, fix tor conn on accounts and settings change
- Limit loading at tix list
- Sort txs by confirmation status to show txs waiting for an action at top
- Ability to delete wallet from the list without opening
- Optimize Tor connection on account switch

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/53
2026-03-05 11:48:23 +00:00
ardocrat beb1a80c6a Payment proofs (#52)
- Ability to export and verify payment proofs

Some fixes:
- Migrated tx heights store from lmdb (also changed heights key from local id to slate_id to avoid conflict between wallets)
- Close address panel on wallet change

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/52
2026-03-03 19:54:46 +00:00
ardocrat 2a41689231 fix: wallet data directory creation 2026-03-03 11:31:32 +03:00
ardocrat dda3be7f86 fix: check external connection url format 2026-03-03 09:07:30 +03:00
ardocrat 65e9546f81 tor: reduce service check delay 2026-02-27 23:42:38 +03:00
ardocrat 8f1175ff1a tor: optimize service check 2026-02-27 22:09:47 +03:00
ardocrat 0ca2c7f372 fix: button rounding 2026-02-27 16:20:15 +03:00
ardocrat ee4752a95f Ability to change data location (#50)
Closes https://code.gri.mw/GUI/grim/issues/9
- Change node chain data directory
- Change wallet data directory
- Check for valid bridge Tor binary

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/50
2026-02-27 00:29:55 +00:00
ardocrat 366bbaeac6 gui: glow renderer only for macos 2026-02-27 01:24:44 +03:00
ardocrat 4e1ada3188 github: add windows msi to release 2026-02-26 15:12:28 +00:00
ardocrat a499c91619 fix: do not hang on tor service launch at ui
- Retrieve secret key on wallet sync to prevent lock at UI

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/51
2026-02-26 14:12:39 +00:00
ardocrat 9f2ad32031 wix: update version 2026-02-25 17:18:44 +00:00
ardocrat 431cda358f github: download win msi 2026-02-25 17:14:38 +00:00
ardocrat 149555cc0a ci: windows msi build
- Separate runner for Windows build
- Create .msi release format

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/49
2026-02-25 17:13:32 +00:00
ardocrat 72de1d5c05 conn: add grinffindor.org to wallet connections and seed list 2026-02-21 19:32:26 +03:00
ardocrat b4c64dae6b fix: fonts setup on first draw 2026-02-20 16:53:17 +03:00
ardocrat e334386fe2 fix: disable modal closing on qr scan at messages 2026-02-20 16:53:17 +03:00
ardocrat a03758d383 fix: webtunnel build
- Fix build command execution on non-Windows

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/48
2026-02-20 00:01:28 +00:00
ardocrat 7d75fc2ae0 gui: fixes
- Wgpu renderer by default for Windows
- Fix items borders sizes
- Start camera at  messages on scan press
- Do not show pull-to-refresh on empty tx list
- Do not close non-closeable modal on Back/Esc key press
- Cut long connection name at wallet list
- Fix setting of tx receiver address

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/47
2026-02-19 23:15:24 +00:00
ardocrat a8df3a20ba fix: build on windows
- Added Windows batch file
- Fixed check for empty file on build

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/45
2026-02-19 19:27:08 +00:00
ardocrat 67514b8609 tor: webtunnel support
- Add webtunnel bridge
- Build from https://code.gri.mw/ardocrat/webtunnel to include binary into the build
- Build and run webtunnel for Android

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/44
2026-02-18 13:38:11 +00:00
ardocrat 3a23438e17 fix: check wallet state from node, build: update common deps, tor: optimize running service check, p2p: async peer saving, update seeds
- Update common dependencies
- Optimize check of running Tor service
- Async peer saving
- Add mainnet and testnet seeds
- Remove grinnode.live from default external connections

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/43
2026-02-10 11:54:59 +00:00
ardocrat 86d4fde77d tx: parse message on input change 2026-02-09 13:50:46 +00:00
ardocrat b751aa256e txs: text slatepack format
Reviewed-on: https://code.gri.mw/GUI/grim/pulls/42
2026-02-09 13:14:08 +00:00
ardocrat b2ef91e67d ci: cache for every platform
Reviewed-on: https://code.gri.mw/GUI/grim/pulls/41
2026-02-08 16:24:43 +00:00
ardocrat b54fd3251f feat: calculate fee and maximum amount on send
Reviewed-on: https://code.gri.mw/GUI/grim/pulls/32
2026-02-07 13:11:23 +00:00
ardocrat 9bb5f1d66a camera: update nokhwa to 0.10.10
Update camera lib, also to avoid git dependency for macos.

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/40
2026-02-07 09:10:20 +00:00
ardocrat e56058ff33 ci: fix cargo registry 2026-02-06 16:59:30 +00:00
ardocrat 45473ded7e ci: optimize build
- Optimize jobs dependencies
- Optimize Android and Linux x86 cache
- Telegram link to all releases

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/39
2026-02-06 16:11:36 +00:00
ardocrat 6eec01bad6 ci: linux x86 build, cargo registry
- Build Linux x86 on separate runner (fix Appimage on x86 platform)
- Use Cargo Nexus registry
- Build at single file
- Fix previous release check for tag

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/38
2026-02-06 07:52:15 +00:00
ardocrat 35dbc3eca9 ci: fix workflow vars, artifacts repo name path 2026-01-30 15:34:25 +03:00
ardocrat 94bae256af ci: cache, local maven for android build and artifacts, fix telegram upload
Reviewed-on: https://code.gri.mw/GUI/grim/pulls/37
2026-01-30 11:42:19 +00:00
ardocrat dae59744b3 fix: github release 2026-01-28 18:30:51 +01:00
ardocrat 7d28a31e18 ci: macos universal build 2026-01-26 08:51:39 +03:00
ardocrat 6d5445f72f ci: telegram notify url 2026-01-26 08:38:14 +03:00
ardocrat 04417f1f53 ci: fix telegram upload 2026-01-26 08:30:16 +03:00
ardocrat 97239ba0f5 android: fix manifest permissions 2026-01-26 01:30:30 +03:00
ardocrat 51898404db ci: tg files upload 2026-01-26 01:29:58 +03:00
ardocrat 2ca7c03999 build: fix nokhwa dep path 2026-01-26 01:29:15 +03:00
ardocrat 86187e4e59 ci: github release fix, do not build on master fork, notify group on pr
- Do not build on master branch fork
- Fix Github release build
- Notify group and channel on PR

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/35
2026-01-23 14:00:34 +01:00
ardocrat 7ebfaaf477 ci: separate android runner, github release download, telegram notifications and release upload
Reviewed-on: https://code.gri.mw/GUI/grim/pulls/33
2026-01-23 11:20:15 +01:00
ardocrat 00f8eb7d18 gui: eframe default features, add wgpu dep, try wgpu on windows error
Reviewed-on: https://code.gri.mw/GUI/grim/pulls/34
2026-01-19 21:52:33 +01:00
ardocrat 0713ba0213 gui: wgpu fallback renderer for desktop (#31)
Reviewed-on: https://code.gri.mw/GUI/grim/pulls/31
2026-01-19 21:21:13 +01:00
ardocrat ec81ba2cee readme: update build instruction 2026-01-09 23:54:39 +00:00
ardocrat cc5831358a ci: checkout submodules
Update checkout actions.

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/30
2026-01-09 23:38:08 +00:00
ardocrat 961e65be4c build: grin submodules
Use node and wallet submodules to avoid dependency conflicts inside grin-wallet on grin repo update.

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/29
2026-01-09 23:08:34 +00:00
ardocrat 12b6626624 readme: fix images links
To correctly show on Github.

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/28
2026-01-09 11:07:37 +00:00
ardocrat 03fbb0914e wallet: optimize proxy url parse to use localhost or dns record 2025-11-25 23:49:05 +03:00
ardocrat ed2dc880aa android: migrate back to gameactivity, update to API 36, fix back navigation at network panel 2025-11-21 01:34:51 +03:00
ardocrat 646a7c5e04 fix: tx ui on repost 2025-11-21 01:33:53 +03:00
ardocrat 11a5a73775 sync: increase header size 2025-11-11 00:38:00 +03:00
ardocrat 48ec553e0a build: android version naming 2025-11-11 00:36:49 +03:00
ardocrat a567243716 ci: fix version naming and override 2025-11-11 00:35:26 +03:00
ardocrat 4773fdb8d5 fix: render on linux wayland 2025-11-10 15:59:22 +03:00
ardocrat 7f65471ba1 ui: fix wallet tabs state 2025-11-06 14:01:40 +03:00
ardocrat fabd0a90df build: remove tag symbol from android version 2025-11-06 13:21:16 +03:00
ardocrat 6093d2bddb build: fix android version naming 2025-11-06 13:19:11 +03:00
ardocrat 42bcda621a ui: update egui, fix android back button 2025-11-05 18:11:07 +03:00
ardocrat 215b5d3f27 ci: jobs deps 2025-11-05 18:09:38 +03:00
ardocrat 97d8b86d39 ci: forgejo 2025-11-05 13:26:05 +03:00
ardocrat 8ba11daf31 build: fix lifetime warning 2025-11-03 13:48:34 +03:00
ardocrat fe2f79ecad tor: update arti to 0.36 2025-11-03 13:48:18 +03:00
ardocrat 606072ca3a build: working android release by default 2025-10-27 17:21:37 +03:00
ardocrat 2eef58e23a Update images links 2025-10-22 23:55:17 +03:00
ardocrat cf4f0789a3 build: update egui to last github version 2025-06-25 13:13:31 +03:00
ardocrat 1b78118f51 fix: action repeat tor tx, no resend for tor 2025-06-25 12:51:29 +03:00
ardocrat a89a9bcaed fix: message opening 2025-06-25 11:50:49 +03:00
ardocrat 8528c33be5 fix: message opening when slate with previous state exists 2025-06-25 11:45:26 +03:00
ardocrat d1502e26b1 wallet: cleanup broadcasting delay on repost 2025-06-25 11:32:26 +03:00
ardocrat 2f56defffa wallet: broadcasting delay, repeat tx action 2025-06-25 11:08:57 +03:00
ardocrat 01af084568 build: update grin and tor deps 2025-06-19 09:34:20 +03:00
ardocrat cd0e3485c5 txs: async tasks for wallet 2025-06-19 09:18:20 +03:00
ardocrat b540fcbf19 tor: do not start already starting service 2025-06-11 15:16:33 +03:00
ardocrat 7d29b2af6d tx: qr padding, info buttons positions 2025-06-10 22:25:10 +03:00
ardocrat ad030fe811 fix: tx finalizing status setup 2025-06-10 22:16:51 +03:00
ardocrat fae1364f10 wallet: tx response flag to show sharing controls 2025-06-10 22:03:49 +03:00
ardocrat 93297b5401 tx: do not show sharing content when can not finalize 2025-06-10 21:20:27 +03:00
ardocrat 511611f994 wallet: show only txs with slate id 2025-06-10 20:40:50 +03:00
ardocrat e9e2a0a8e7 ui: fix tx description 2025-06-10 20:25:23 +03:00
ardocrat 1222399926 tx: remove manual slatepack input, scan outputs after wallet db deletion 2025-06-10 20:09:24 +03:00
ardocrat 845c1dc0ea i18n: file 2025-06-10 19:34:35 +03:00
ardocrat 3a21e60e19 ui: do not copy form animated qr 2025-06-10 19:30:36 +03:00
ardocrat 9622429180 build: remove unused module 2025-06-10 19:04:56 +03:00
ardocrat d04b7a4e6a build: update version name 2025-06-09 12:51:07 +03:00
ardocrat 8b369b6049 ui: refactoring of wallet screen, fix colors 2025-06-09 12:34:07 +03:00
ardocrat b54a573f61 tor: proxy settings 2025-06-09 12:27:36 +03:00
ardocrat 184326bfde wallet: open slatepack 2025-06-09 12:23:01 +03:00
ardocrat b1f3c7d42b fix: mnemonic input 2025-06-06 14:29:29 +03:00
ardocrat 53a96e567d wallet: sort accounts to show current first 2025-06-04 15:33:34 +03:00
ardocrat 20daa7b465 network: fix external connections check 2025-06-03 16:11:08 +03:00
ardocrat 0fa2ef4283 qr: smaller text 2025-06-02 21:54:13 +03:00
ardocrat e067a0a900 qr: add max size support, ui copy button 2025-06-02 21:03:49 +03:00
ardocrat 31d8e2f012 eframe: glow renderer 2025-06-02 12:10:41 +03:00
ardocrat 84d385ef1a macos: glow renderer 2025-06-01 23:11:57 +03:00
ardocrat fabef9492e proxy: tls support 2025-06-01 00:05:48 +03:00
ardocrat 92f8386264 http: client, wallet to node communication with proxy 2025-05-31 23:34:51 +03:00
ardocrat 1ef62a806b fix: show word list on wallet creation 2025-05-31 22:34:12 +03:00
ardocrat f8da3d0754 fix: hyper client import 2025-05-31 17:44:37 +03:00
ardocrat 8165fab326 tor: update arti-client to 0.30.0 2025-05-31 17:08:39 +03:00
ardocrat 918c5b4355 build: imports 2025-05-31 15:47:03 +03:00
ardocrat f930cd4ade config: node db path 2025-05-31 15:45:36 +03:00
ardocrat 3f3940e752 ui: remove storage settings 2025-05-31 15:45:15 +03:00
ardocrat 4ef5dd839d platform: pick folder 2025-05-31 15:44:24 +03:00
ardocrat fd14700eae settings: network proxy 2025-05-31 14:12:31 +03:00
ardocrat e5548eb6f1 fix: current locale check at modal 2025-05-31 09:20:46 +03:00
ardocrat a364daf52e ui: network and storage settings modules, language selection modal 2025-05-31 09:11:07 +03:00
ardocrat 7089e6e1b2 ui: update app title 2025-05-31 09:09:01 +03:00
ardocrat 0621154902 ui: remove on_back callback from content container 2025-05-30 22:11:16 +03:00
ardocrat acfb5fec1a ui: wallet content container, accounts panel 2025-05-30 21:25:29 +03:00
ardocrat 1a3df4619e ui: accounts module 2025-05-30 16:13:27 +03:00
ardocrat 8994775be2 fix: keyboard focus 2025-05-30 15:06:47 +03:00
ardocrat 81365dbe6a ui: reset keyboard window state on opening and inputs focus change 2025-05-30 14:48:49 +03:00
ardocrat 7ae63b2b66 fix: modal window focus 2025-05-30 14:14:58 +03:00
ardocrat b8dd5911d4 ui: animate wallet list panels 2025-05-30 13:01:12 +03:00
ardocrat 3fc4ffa179 fix: wallet and mnemonic modals for container 2025-05-30 12:50:33 +03:00
ardocrat b84f6480e7 ui: content container 2025-05-30 12:33:13 +03:00
ardocrat 5dd8de7950 modal: move focus setup to the root content 2025-05-29 23:50:26 +03:00
ardocrat 78baaca4a3 fix: keyboard modal focus 2025-05-29 15:37:00 +03:00
ardocrat e597ac7e4b ui: ability to not show soft keyboard for input, move modal on top only at first draw 2025-05-29 13:32:33 +03:00
ardocrat 4d5cc93a38 ui: settings content at separate panel 2025-05-29 12:56:49 +03:00
ardocrat ed50132d5e keyboard: long press clear 2025-05-29 01:31:12 +03:00
ardocrat fbb084f636 wallet: do not scan outputs for new wallet 2025-05-29 00:38:45 +03:00
ardocrat d42ef102b2 keyboard: layouts for languages 2025-05-28 20:16:23 +03:00
ardocrat 9673c7d719 keyboard: show refactoring 2025-05-28 13:46:44 +03:00
ardocrat 9b4623c558 keyboard: state refactoring 2025-05-28 12:58:57 +03:00
ardocrat b7563e63c1 ui: esc key handling for keyboard without modal 2025-05-28 11:11:22 +03:00
ardocrat 4d4b5eb007 keyboard: optimize buttons 2025-05-28 10:23:17 +03:00
ardocrat 6c04eec026 modal: close refactoring 2025-05-27 22:14:43 +03:00
ardocrat 1ff2b27edc android: release build script 2025-05-27 22:04:55 +03:00
ardocrat 6bce9ec071 android: switch to nativeactivity, fix clicks 2025-05-27 21:01:00 +03:00
ardocrat 98619cc362 ui: update to egui 0.31 2025-05-27 16:10:29 +03:00
ardocrat 1987d0553c ui: numeric keyboard input 2025-05-27 14:28:23 +03:00
ardocrat 3f78095fe3 ui: keyboard language switch 2025-05-27 13:08:32 +03:00
ardocrat 245766e1b5 fix: text width inside input content 2025-05-26 22:37:03 +03:00
ardocrat 2591653f66 ui: input refactoring 2025-05-26 20:48:29 +03:00
ardocrat d11e90226b feat: software keyboard (without language switch) 2025-05-23 19:20:42 +03:00
ardocrat fb159c17a0 i18n: chinese 2025-04-27 21:22:08 +03:00
ardocrat f7eb6580cc tor: trim address on send 2025-04-27 19:51:06 +03:00
ardocrat 43720b34ba fix: external connection deletion 2025-04-23 15:10:48 +03:00
ardocrat f1f0f002ce fix: content redraw at connections 2025-04-23 13:09:31 +03:00
ardocrat 86afa21a60 node: do not remove lock file on cleanup 2025-04-23 12:38:23 +03:00
ardocrat 0169acba81 build: use zig linker for macos and linux for arm on x86 2025-04-02 22:30:59 +03:00
ardocrat 073d950d41 github: disable release build 2025-04-02 21:10:45 +03:00
ardocrat 4eaaebd739 release: v0.2.4 2025-04-02 20:48:58 +03:00
ardocrat a9e2106fda git: ignore cargo parse result file 2025-04-02 20:48:11 +03:00
ardocrat 8b427989c5 github: disable release build 2025-04-02 20:37:47 +03:00
ardocrat f16ce3c69b fix: transparent background on desktop 2025-04-02 20:37:23 +03:00
ardocrat a1b3330e5e async: use tokio for thread block calls 2025-04-02 19:15:20 +03:00
ardocrat 3da8f5420b build: update tor arti 0.29.0 2025-04-02 17:05:20 +03:00
ardocrat 109e896506 tor: clean error after start 2025-04-02 16:47:07 +03:00
ardocrat 8ad38f381e ui: change values on enter press at node settings modals 2025-04-02 15:49:07 +03:00
ardocrat 1e32315346 win: use system window frame 2025-04-02 15:22:15 +03:00
ardocrat ef8c645a6a win: allow downgrade install 2025-04-02 14:32:00 +03:00
ardocrat 15ecdf1e57 build: update guid for win installer 2025-04-02 13:31:04 +03:00
ardocrat 587b00c93a build: version for windows 2025-04-01 00:26:59 +03:00
ardocrat aba2bead27 build: update package info, other dependencies 2025-03-31 21:21:51 +03:00
ardocrat 85ce58f69c fix: parse result from scan on top panel 2025-03-31 20:46:23 +03:00
ardocrat bb7e00b0eb fix: initial color theme setup 2025-03-29 21:52:10 +03:00
ardocrat d60b35ebef Merge pull request 'macos: use nokhwa camera dependency' (#16) from macos_camera_fix into master
Reviewed-on: https://gri.mw/code/code/GUI/grim/pulls/16
2025-03-29 21:36:25 +03:00
ardocrat eb60c52224 macos: use nokhwa camera dependency 2025-03-29 21:18:53 +03:00
ardocrat 61828ea2db build: update tor lib 2025-03-15 20:41:30 +03:00
ardocrat 7e819e14d1 node: fix peers config saving 2025-03-15 20:35:10 +03:00
ardocrat 1d9b7d9698 wallet: do not lock whole balance on send 2025-01-14 17:55:50 +03:00
ardocrat 82c05588bc readme: update title 2025-01-13 21:59:22 +03:00
ardocrat 1cddd05bc0 readme: update img tag 2025-01-13 21:58:29 +03:00
ardocrat 8ad0d1c461 readme: update images 2025-01-13 21:56:48 +03:00
ardocrat a22a75913c img: add grin logo 2025-01-13 21:55:55 +03:00
ardocrat e797da0ed8 img: add cover 2025-01-13 21:26:00 +03:00
ardocrat 6936c14ed2 tor: remove macos tls fix 2025-01-13 21:06:34 +03:00
ardocrat c626ed5a48 tor: clear data on launch, update arti to 0.26.0 2025-01-13 19:40:09 +03:00
ardocrat d79d05ef5a android: debug build without keystore 2025-01-13 16:54:27 +03:00
ardocrat 094a5b8969 release: v0.2.3 2024-10-27 20:12:12 +03:00
ardocrat 12a75f8370 macos: future version update 2024-10-27 19:45:00 +03:00
ardocrat 1c14b9aa93 tx: fix confirmation status for new block, do not show Slatepack message after finalization 2024-10-27 19:02:17 +03:00
ardocrat 8ea388554a github: macos target 11.0 2024-10-27 18:07:22 +03:00
ardocrat 1531c201bb github: macos 12 2024-10-27 00:46:53 +03:00
ardocrat ed522c56ae github: macos zig linker 2024-10-27 00:40:58 +03:00
ardocrat 4b454ab2f3 github: macos last os 2024-10-27 00:29:27 +03:00
ardocrat f6fbf7226e fix: window size saving 2024-10-26 23:54:47 +03:00
ardocrat ebd09ab1c8 camera: update nokhwa, eye for macos, ability to switch camera when another camera not loaded 2024-10-26 23:25:55 +03:00
ardocrat 75cf7edc96 fix: modal padding and window border on desktop 2024-10-26 23:23:39 +03:00
ardocrat 5c8b9c40be build: provide version for android release 2024-10-26 02:17:28 +03:00
ardocrat dcaf9945c8 ui: wgpu renderer for macos, desktop content background fix, do not show left line for camera content at dual panel mode 2024-10-26 02:16:47 +03:00
ardocrat f9426287d5 macos: release on darwin without zig, info.plist camera usage description and version update 2024-10-25 20:03:57 +03:00
ardocrat 77281e3ab9 github: fix macos arm sdk 2024-10-23 00:37:41 +03:00
ardocrat 64439ad3d3 github: fix macos deployment target 2024-10-23 00:11:45 +03:00
ardocrat 9494c1292e github: macos coreutils 2024-10-22 23:47:19 +03:00
ardocrat accf123d49 github: macos build 2024-10-22 23:24:02 +03:00
ardocrat d77598c259 github: fix macos sdk env 2024-10-22 04:39:08 +03:00
ardocrat 4e6dff52fe github: macos install zig 2024-10-22 04:14:21 +03:00
ardocrat 92d0aac250 github: fix macos sdk unzip 2024-10-22 04:08:26 +03:00
ardocrat 5ef310558a release: v0.2.2 2024-10-22 03:51:01 +03:00
ardocrat 683821b667 build: fix version script 2024-10-22 03:50:48 +03:00
ardocrat da4cf71fac github: build macos on linux with SDK 10.15 2024-10-22 03:19:34 +03:00
ardocrat f81ceae940 txs: new block confirmation time 2024-10-22 02:11:25 +03:00
ardocrat fa6301a1db stratum: fix wallet name after selection, do not panic after stop 2024-10-22 00:12:13 +03:00
ardocrat 442fc425f7 ui: update to egui 0.29.1, wallet qr scan content, panels strokes and colors refactoring, check closeable modal at desktop title, fix app socket name 2024-10-21 12:03:09 +03:00
ardocrat ea61588ede build: check android lib result 2024-10-12 19:58:14 +03:00
ardocrat 7f67aa134a build: increment on android development 2024-10-12 15:33:23 +03:00
ardocrat d7d1c53c52 build: incremental release on desktop development 2024-10-12 15:26:01 +03:00
ardocrat 18f52f877a node: remove delay after server start 2024-10-12 15:24:15 +03:00
ardocrat c13195bd61 stratum: prevent crash at connections thread 2024-10-10 21:51:28 +03:00
ardocrat e40d5b6474 node: single function to get api secrets 2024-10-10 21:11:50 +03:00
ardocrat 92e5d38755 build: update grin 5.3.3, arti 0.23.0 (fork arti-hyper crate) and non-egui dependencies 2024-10-09 12:58:59 +03:00
ardocrat ec7e795ba9 build: camera features 2024-10-09 10:13:55 +03:00
ardocrat af220b2a09 camera: remove eye-rs to fix build for mac, horizontally flip image 2024-10-08 23:23:04 +03:00
ardocrat 846e30cb38 app: better panic handling, macos single app instance 2024-10-08 17:11:45 +03:00
ardocrat d371d4368b wallet: disable tor listener by default 2024-10-08 14:59:51 +03:00
ardocrat 85fc8101e4 ui: show tx modal on error if exists 2024-10-08 02:37:51 +03:00
ardocrat e2f58a8938 android: update gradle 2024-10-07 20:55:23 +03:00
ardocrat 7e6954afd9 fix: opened file data providing 2024-10-07 19:45:29 +03:00
ardocrat bed041a1c3 git: ignore android release artifacts 2024-09-21 00:33:46 +03:00
ardocrat f955f720d2 release: v0.2.1 2024-09-20 23:33:08 +03:00
ardocrat b627ac1ca6 fix: mnemonic import 2024-09-20 23:30:41 +03:00
ardocrat ac0b218376 fix: connection selection 2024-09-20 23:12:44 +03:00
ardocrat 04bf5a5349 github: coreutils for macos 2024-09-20 21:46:17 +03:00
ardocrat 9cce52a7d9 github: fix sha256sum 2024-09-20 20:38:05 +03:00
ardocrat 51e0d87d27 github: fix release 2024-09-20 15:17:41 +03:00
ardocrat d6f7e2e976 github: release sha256sum 2024-09-20 15:15:18 +03:00
ardocrat 0bbf395a62 build: android warning fix 2024-09-20 15:03:56 +03:00
ardocrat 609d7ceb7a build: remove panic message dependency 2024-09-20 14:45:40 +03:00
ardocrat b91605864d github: fix macos release 2024-09-20 14:42:37 +03:00
ardocrat 7857b708c9 release: v0.2.0 2024-09-20 14:17:03 +03:00
ardocrat a0f85538e9 ui: tx modal height 2024-09-20 14:09:53 +03:00
ardocrat c52da4f479 wallet: accounts balance calculating optimization, payment proof support on send, selection_strategy_is_use_all 2024-09-20 13:56:25 +03:00
ardocrat af597df7b1 i18n: move confirmation word 2024-09-20 13:49:31 +03:00
ardocrat 2adb29f4ee ui: external connection check and ui repaint fix, tab button callback argument 2024-09-20 13:42:45 +03:00
ardocrat 2b83944f34 ui: show node error status on connection item 2024-09-20 11:10:05 +03:00
ardocrat 71e80f6df7 ui: reset node config from ui on error 2024-09-20 10:58:52 +03:00
ardocrat 0ead11ec6c tx: receiver address 2024-09-20 02:39:06 +03:00
ardocrat 3e249c5314 android: share file type 2024-09-20 00:16:12 +03:00
ardocrat bacc87945c messages: qr scan modal 2024-09-20 00:09:08 +03:00
ardocrat 2cfd428c4c ui: do not clear qr state 2024-09-19 21:39:59 +03:00
ardocrat c155deedb5 wallet: qr scan modal, connections content and default list, wallet creation and list refactoring, tx height 2024-09-19 15:56:53 +03:00
Ardocrat 3bc8c407b4 Merge pull request #13 from ardocrat/slatepack_ext_file
Open .slatepack file with the app
2024-09-16 16:08:27 +00:00
ardocrat c3fae38d5c desktop: open camera check 2024-09-15 15:54:07 +03:00
ardocrat d6ec4213ab ui: ability to finalize tx only when wallet is loaded 2024-09-14 21:21:03 +03:00
ardocrat 150a0de1c4 android: always build with release-apk profile 2024-09-14 21:17:43 +03:00
ardocrat 7cedebc70e ui: qr scan and accounts modals module, parsing messages fix 2024-09-14 21:11:52 +03:00
ardocrat fe5aca6f0e build: remove debug from release profile 2024-09-14 16:08:40 +03:00
ardocrat 5d83710fed ui: dark colors fix 2024-09-14 16:02:20 +03:00
ardocrat 1431e307ee ui: separate wallet accounts modal 2024-09-14 15:21:08 +03:00
ardocrat 1934dc3377 desktop: args text 2024-09-14 15:04:11 +03:00
ardocrat 8af06d8860 build: android fix 2024-09-14 13:07:48 +03:00
ardocrat 9ea0da95b7 build: release sha256sum 2024-09-14 12:12:50 +03:00
ardocrat d39e2ec21e build: android signed release 2024-09-14 02:06:35 +03:00
ardocrat 68c9c9df04 build: local android release 2024-09-14 01:47:06 +03:00
ardocrat 6f7156ef17 github: android secrets 2024-09-13 22:31:28 +03:00
ardocrat 50638ff54e github: android keystore 2024-09-13 22:00:59 +03:00
ardocrat 8594279b98 android: java call result fixes 2024-09-13 21:08:14 +03:00
ardocrat 0205e01b3c build: macos fix 2024-09-13 19:51:33 +03:00
ardocrat 17545c1b7c macos: platform build 2024-09-13 18:57:09 +03:00
ardocrat bcf821c06a macos: initial file type association 2024-09-13 15:21:43 +03:00
ardocrat 34376d3490 build: fix macos 2024-09-13 14:56:04 +03:00
ardocrat 8ed2308340 macos: build, warn fix 2024-09-13 14:53:22 +03:00
ardocrat c73cd58eed platform: android file opening, better exit 2024-09-13 14:22:15 +03:00
ardocrat d78ec570b0 platform: passed data at lib, desktop user attention, check existing file on share at android 2024-09-12 21:27:37 +03:00
ardocrat dd45f7ce38 desktop: platform socket fix, file extension association for windows 2024-09-12 18:02:02 +03:00
ardocrat fb7312cb80 desktop: request window focus on data 2024-09-11 21:13:52 +03:00
ardocrat dbc28205e8 desktop: parse file content from argument on launch, single app instance, wallets selection and opening modals refactoring 2024-09-11 17:01:05 +03:00
ardocrat a3ed3bd234 build: linux release 2024-09-07 12:45:05 +03:00
ardocrat 21ecf200b8 wallet + ui: optimize sync after tx actions, remove tx repost, share message as file from tx modal, show tx info after tor sending and message creation or finalization, messages and transport modules refactoring, qr code text optimization, wallet dandelion setting, recovery phrase modal next step on enter 2024-09-07 00:11:17 +03:00
ardocrat c8bca08bdc txs: share message as file from modal, module refactoring 2024-08-15 23:09:42 +03:00
ardocrat 68bd2b81ec peers: fix config edit and load, default mainnet dnsseed 2024-08-13 02:31:38 +03:00
ardocrat 09cfb84b94 fix: ellipsized sync status text at connections 2024-08-12 18:30:10 +03:00
ardocrat 5c1ffb5636 build: push version 2024-08-10 12:15:40 +03:00
ardocrat 7f79cc0708 release: v0.1.3 2024-08-10 12:08:20 +03:00
ardocrat b0b4f9068a build: version release 2024-08-10 11:59:12 +03:00
ardocrat cb9e86750c mnemonic: words import and errors check refactoring 2024-08-10 02:35:42 +03:00
ardocrat 86fbf2e14f github: fix android build 2024-08-08 03:08:10 +03:00
ardocrat e0351cea84 fix: mnemonic words size on creation, wallet creation errors 2024-08-08 03:01:08 +03:00
ardocrat 040fab6ff8 Merge branch 'master' of github.com:GetGrin/grim 2024-08-07 19:21:59 +03:00
ardocrat f3db1005b5 feat: crash report 2024-08-07 19:14:11 +03:00
Ardocrat 0c1e279215 github: fix android build 2024-08-04 09:30:00 +00:00
Ardocrat 36168442a9 github: build on push 2024-08-04 08:57:10 +00:00
Ardocrat 457db333d9 build: fix android github 2024-08-03 18:12:06 +00:00
237 changed files with 58325 additions and 24797 deletions
+21
View File
@@ -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"
+37
View File
@@ -0,0 +1,37 @@
name: Build
on: [push, pull_request]
jobs:
linux:
name: Linux Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
# nip44 is a path dep on ../nip44; materialize it before building.
- uses: ./.github/actions/fetch-nip44
- name: Release build
run: cargo build --release
windows:
name: Windows Build
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: ./.github/actions/fetch-nip44
- name: Release build
run: cargo build --release
macos:
name: MacOS Build
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: ./.github/actions/fetch-nip44
- name: Release build
run: cargo build --release
+122 -235
View File
@@ -1,258 +1,145 @@
# Release builds on native runners — one per platform, no cross-compilation
# (nokhwa's camera backends want each platform's own SDK; see NEXT-STEPS judgment).
#
# Manually triggered (Actions → Release → Run workflow) against an existing tag
# until a run has been validated end-to-end; then this can move to a tag trigger.
# Android is built locally via scripts/android.sh for now — the gradle `ci`
# flavor expects maven credentials this repository does not carry.
name: Release
on:
push:
tags:
- "v*.*.*"
# 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:
description: "Existing release tag to build and upload artifacts to (e.g. build27)"
required: true
permissions:
contents: write
env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
jobs:
android_release:
name: Android Release
linux:
name: Linux x86_64
runs-on: ubuntu-latest
# Built locally and uploaded with the release; only run on manual dispatch.
if: github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Setup Rust build
run: |
cargo install cargo-ndk
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
- name: Setup Java build
run: |
chmod +x android/gradlew
echo "${{ secrets.ANDROID_RELEASE_KEYSTORE }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.ANDROID_RELEASE_SECRET }}" --batch release.keystore.asc > android/keystore
echo -e "storePassword=${{ secrets.ANDROID_PASS }}\nkeyPassword=${{ secrets.ANDROID_PASS }}\nkeyAlias=grim\nstoreFile=../keystore" > android/keystore.properties
- name: Build lib ARMv8 1/2
continue-on-error: true
run: |
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t arm64-v8a build --profile release-apk
- name: Build lib ARMv8 2/2
run: |
unset CPPFLAGS && unset CFLAGS && cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --profile release-apk
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
- name: Build APK ARMv8
working-directory: android
run: |
./gradlew assembleRelease
mv app/build/outputs/apk/release/app-release.apk grim-${{ github.ref_name }}-android-armv8.apk
- name: Checksum APK ARMv8
working-directory: android
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-android-armv8.apk | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-android-armv8-sha256sum.txt
- name: Build lib ARMv7 1/2
continue-on-error: true
run: |
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t armeabi-v7a build --profile release-apk
- name: Build lib ARMv7 2/2
run: |
unset CPPFLAGS && unset CFLAGS && cargo ndk -t armeabi-v7a -o android/app/src/main/jniLibs build --profile release-apk
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
- name: Build APK ARMv7
working-directory: android
run: |
rm -rf app/build
./gradlew assembleRelease
mv app/build/outputs/apk/release/app-release.apk grim-${{ github.ref_name }}-android-armv7.apk
- name: Checksum APK ARMv7
working-directory: android
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-android-armv7.apk | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-android-armv7-sha256sum.txt
- name: Build lib x86 1/2
continue-on-error: true
run: |
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t x86_64 build --profile release-apk
- name: Build lib x86 2/2
run: |
unset CPPFLAGS && unset CFLAGS && cargo ndk -t x86_64 -o android/app/src/main/jniLibs build --profile release-apk
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
- name: Build APK x86
working-directory: android
run: |
rm -rf app/build
./gradlew assembleRelease
mv app/build/outputs/apk/release/app-release.apk grim-${{ github.ref_name }}-android-x86_64.apk
- name: Checksum APK x86
working-directory: android
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-android-x86_64.apk | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-android-x86_64-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-nip44
- name: Build
shell: bash
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
- name: Package
run: |
tar -C target/release -czf "goblin-$TAG-linux-x86_64.tar.gz" goblin
sha256sum "goblin-$TAG-linux-x86_64.tar.gz" > "goblin-$TAG-linux-x86_64-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
android/grim-${{ github.ref_name }}-android-armv8.apk
android/grim-${{ github.ref_name }}-android-armv8-sha256sum.txt
android/grim-${{ github.ref_name }}-android-armv7.apk
android/grim-${{ github.ref_name }}-android-armv7-sha256sum.txt
android/grim-${{ github.ref_name }}-android-x86_64.apk
android/grim-${{ github.ref_name }}-android-x86_64-sha256sum.txt
goblin-${{ inputs.tag || github.event.release.tag_name }}-linux-x86_64.tar.gz
goblin-${{ inputs.tag || github.event.release.tag_name }}-linux-x86_64-sha256sum.txt
linux_release:
name: Linux Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download appimagetools
run: |
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
sudo apt install libfuse2
- name: Zig Setup
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.12.1
- name: Install cargo-zigbuild
run: cargo install cargo-zigbuild
- name: Release x86
run: cargo zigbuild --release --target x86_64-unknown-linux-gnu
- name: Release ARM
run: |
rustup target add aarch64-unknown-linux-gnu
cargo zigbuild --release --target aarch64-unknown-linux-gnu
- name: AppImage x86
run: |
cp target/x86_64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
./appimagetool-x86_64.AppImage linux/Grim.AppDir target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64.AppImage
- name: Checksum AppImage x86
working-directory: target/x86_64-unknown-linux-gnu/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-linux-x86_64.AppImage | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-linux-x86_64-appimage-sha256sum.txt
- name: AppImage ARM
run: |
cp target/aarch64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
./appimagetool-x86_64.AppImage linux/Grim.AppDir target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm.AppImage
- name: Checksum AppImage ARM
working-directory: target/aarch64-unknown-linux-gnu/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-linux-arm.AppImage | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-linux-arm-appimage-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64.AppImage
target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64-appimage-sha256sum.txt
target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm.AppImage
target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm-appimage-sha256sum.txt
windows_release:
name: Windows Release
windows:
name: Windows x86_64 (MSVC)
runs-on: windows-latest
# Built locally and uploaded with the release; only run on manual dispatch.
if: github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build release
run: cargo build --release
- name: Archive release
uses: vimtor/action-zip@v1
with:
files: target/release/grim.exe
dest: target/release/grim-${{ github.ref_name }}-win-x86_64.zip
- name: Checksum release
working-directory: target/release
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-nip44
- name: Build
shell: bash
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
- name: Build MSI installer (cargo-wix / WiX 3 — same packaging as GRIM)
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-win-x86_64.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-win-x86_64-sha256sum.txt
- name: Install cargo-wix
run: cargo install cargo-wix
- name: Run cargo-wix
run: cargo wix -p grim -o ./target/wix/grim-${{ github.ref_name }}-win-x86_64.msi --nocapture
- name: Checksum msi
working-directory: target/wix
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-win-x86_64.msi | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-win-x86_64-msi-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
run: |
# The .msi is built from wix/main.wxs (the cargo-wix default template:
# WixUI_Minimal + launch-after-install), so `cargo wix` wires up the
# WixUI/WixUtil extensions, cultures and CargoTargetBinDir for us. The
# installer + shortcuts + Add/Remove-Programs entry carry wix/Product.ico
# (the yellow Goblin icon). --no-build reuses the release exe above so the
# embedded GOBLIN_BUILD number is preserved.
cargo install cargo-wix --locked
$wix = Get-ChildItem 'C:\Program Files (x86)' -Directory -Filter 'WiX Toolset v3*' -ErrorAction SilentlyContinue | Select-Object -Last 1
if (-not $wix) {
choco install wixtoolset --no-progress -y | Out-Null
$wix = Get-ChildItem 'C:\Program Files (x86)' -Directory -Filter 'WiX Toolset v3*' | Select-Object -Last 1
}
$env:WIX = "$($wix.FullName)\"
$env:PATH = "$($wix.FullName)\bin;$env:PATH"
$msi = "goblin-$env:TAG-win-x86_64.msi"
cargo wix --no-build --nocapture -o "$msi"
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "$msi")) { throw "cargo wix failed to produce $msi" }
(Get-FileHash "$msi" -Algorithm SHA256).Hash.ToLower() + " $msi" | Out-File -Encoding ascii "goblin-$env:TAG-win-x86_64-msi-sha256sum.txt"
- name: Package portable zip
shell: bash
run: |
7z a "goblin-$TAG-win-x86_64.zip" ./target/release/goblin.exe
sha256sum "goblin-$TAG-win-x86_64.zip" > "goblin-$TAG-win-x86_64-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
target/release/grim-${{ github.ref_name }}-win-x86_64.zip
target/release/grim-${{ github.ref_name }}-win-x86_64-sha256sum.txt
target/wix/grim-${{ github.ref_name }}-win-x86_64.msi
target/wix/grim-${{ github.ref_name }}-win-x86_64-msi-sha256sum.txt
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64.msi
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64-msi-sha256sum.txt
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64.zip
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64-sha256sum.txt
macos_release:
name: MacOS Release
macos:
name: macOS universal
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Zig Setup
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.12.1
- name: Install cargo-zigbuild
run: cargo install cargo-zigbuild
- name: Release x86
run: |
rustup target add x86_64-apple-darwin
cargo zigbuild --release --target x86_64-apple-darwin
mkdir macos/Grim.app/Contents/MacOS
yes | cp -rf target/x86_64-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive x86
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-x86_64.zip Grim.app
mv grim-${{ github.ref_name }}-macos-x86_64.zip ../target/x86_64-apple-darwin/release
cd ..
- name: Checksum Release x86
working-directory: target/x86_64-apple-darwin/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-x86_64.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-x86_64-sha256sum.txt
- name: Release ARM
run: |
rustup target add aarch64-apple-darwin
cargo zigbuild --release --target aarch64-apple-darwin
yes | cp -rf target/aarch64-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive ARM
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-arm.zip Grim.app
mv grim-${{ github.ref_name }}-macos-arm.zip ../target/aarch64-apple-darwin/release
cd ..
- name: Checksum Release ARM
working-directory: target/aarch64-apple-darwin/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-arm.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-arm-sha256sum.txt
- name: Release Universal
run: |
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
cargo zigbuild --release --target universal2-apple-darwin
yes | cp -rf target/universal2-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive Universal
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-universal.zip Grim.app
mv grim-${{ github.ref_name }}-macos-universal.zip ../target/universal2-apple-darwin/release
cd ..
- name: Checksum Release Universal
working-directory: target/universal2-apple-darwin/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-universal.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-universal-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-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 into Goblin.app bundle
run: |
# Combine both arches into one universal Mach-O and drop it into the
# app bundle's executable slot (CFBundleExecutable=goblin).
lipo -create -output goblin \
target/aarch64-apple-darwin/release/goblin \
target/x86_64-apple-darwin/release/goblin
cp goblin macos/Goblin.app/Contents/MacOS/goblin
chmod +x macos/Goblin.app/Contents/MacOS/goblin
# Drop the placeholder that kept the empty dir tracked in git.
rm -f macos/Goblin.app/Contents/MacOS/.gitignore
# Ad-hoc sign (no Apple cert in CI). REQUIRED on Apple Silicon: lipo
# strips the per-arch signatures cargo/ld add, and an unsigned arm64
# Mach-O is killed by the OS. Ad-hoc gives a valid (if unidentified)
# signature; users still right-click → Open past Gatekeeper.
codesign --force --sign - macos/Goblin.app/Contents/MacOS/goblin
codesign --force --sign - macos/Goblin.app
# ditto is the macOS-correct way to zip an .app (preserves the bundle
# layout, symlinks and permissions; plain `zip` mangles bundles).
ditto -c -k --keepParent macos/Goblin.app "goblin-$TAG-macos-universal.zip"
shasum -a 256 "goblin-$TAG-macos-universal.zip" > "goblin-$TAG-macos-universal-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
target/x86_64-apple-darwin/release/grim-${{ github.ref_name }}-macos-x86_64.zip
target/x86_64-apple-darwin/release/grim-${{ github.ref_name }}-macos-x86_64-sha256sum.txt
target/aarch64-apple-darwin/release/grim-${{ github.ref_name }}-macos-arm.zip
target/aarch64-apple-darwin/release/grim-${{ github.ref_name }}-macos-arm-sha256sum.txt
target/universal2-apple-darwin/release/grim-${{ github.ref_name }}-macos-universal.zip
target/universal2-apple-darwin/release/grim-${{ github.ref_name }}-macos-universal-sha256sum.txt
goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal.zip
goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal-sha256sum.txt
+22 -3
View File
@@ -1,9 +1,16 @@
*.iml
android/build
android/.idea
android/.gradle
android/local.properties
android/keystore
android/keystore.asc
android/keystore.properties
android/*.apk
android/*.apk.sha256
android/*.AppImage
android/*.AppImage.sha256
android/*sha256sum.txt
/.idea
.DS_Store
/captures
@@ -13,7 +20,19 @@ android/keystore.properties
target
.cargo/
app/src/main/jniLibs
macos/Grim.app/Contents/MacOS/grim
macos/cert.pem
linux/Grim.AppDir/AppRun
.intentionally-empty-file.o
linux/Goblin.AppDir/AppRun
.intentionally-empty-file.o
Cargo.toml-e
screenshots/
# GRIM-canonical build toolchains fetched by scripts/toolchain.sh
.toolchains/
# 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
+7
View File
@@ -0,0 +1,7 @@
[submodule "node"]
path = node
url = https://code.gri.mw/ardocrat/node
[submodule "wallet"]
path = wallet
url = https://code.gri.mw/ardocrat/wallet
branch = grim
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Copyright 2026 The Grim Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
rustfmt --version &>/dev/null
if [ $? != 0 ]; then
printf "[pre_commit] \033[0;31merror\033[0m: \"rustfmt\" not available. \n"
printf "[pre_commit] \033[0;31merror\033[0m: rustfmt can be installed via - \n"
printf "[pre_commit] $ rustup component add rustfmt-preview \n"
exit 1
fi
problem_files=()
# first collect all the files that need reformatting
for file in $(git diff --name-only --cached); do
if [ ${file: -3} == ".rs" ]; then
rustfmt --check $file &>/dev/null
if [ $? != 0 ]; then
problem_files+=($file)
fi
fi
done
if [ ${#problem_files[@]} == 0 ]; then
# nothing to do
printf "[pre_commit] rustfmt \033[0;32mok\033[0m \n"
else
# reformat the files that need it and re-stage them.
printf "[pre_commit] the following files were rustfmt'd before commit: \n"
for file in ${problem_files[@]}; do
rustfmt $file
git add $file
printf "\033[0;32m $file\033[0m \n"
done
fi
exit 0
Generated
+5754 -3832
View File
File diff suppressed because it is too large Load Diff
+188 -84
View File
@@ -1,21 +1,28 @@
[package]
name = "grim"
version = "0.1.0"
authors = ["Ardocrat <ardocrat@proton.me>"]
description = "Cross-platform GUI for Grin with focus on usability and availability to be used by anyone, anywhere."
version = "0.3.6"
authors = ["Ardocrat <ardocrat@gri.mw>"]
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and Tor handled for you."
license = "Apache-2.0"
repository = "https://github.com/ardocrat/grim"
keywords = [ "crypto", "grin", "mimblewimble" ]
edition = "2021"
repository = "https://code.gri.mw/GUI/grim"
keywords = [ "crypto", "grin", "mimblewimble", "nostr" ]
edition = "2024"
build = "build.rs"
[[bin]]
name = "grim"
name = "goblin"
path = "src/main.rs"
[lib]
name="grim"
crate-type = ["rlib"]
# Desktop/CI release binaries ship stripped of debug symbols — the nym + nostr +
# grin tree leaves a large symbol table that's dead weight for users (~16 MB on
# Linux). opt-level stays at the default 3 for wallet/runtime speed.
[profile.release]
strip = true
[profile.release-apk]
inherits = "release"
strip = true
@@ -24,107 +31,204 @@ 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"
log = "0.4.27"
## node
openssl-sys = { version = "0.9.82", features = ["vendored"] }
grin_api = "5.3.1"
grin_chain = "5.3.1"
grin_config = "5.3.1"
grin_core = "5.3.1"
grin_p2p = "5.3.1"
grin_servers = "5.3.1"
grin_keychain = "5.3.1"
grin_util = "5.3.1"
# node
grin_api = { path = "node/api" }
grin_chain = { path = "node/chain" }
grin_config = { path = "node/config" }
grin_core = { path = "node/core" }
grin_p2p = { path = "node/p2p" }
grin_servers = { path = "node/servers" }
grin_keychain = { path = "node/keychain" }
grin_util = { path = "node/util" }
## wallet
grin_wallet_impls = "5.3.1"
grin_wallet_api = "5.3.1"
grin_wallet_libwallet = "5.3.1"
grin_wallet_util = "5.3.1"
grin_wallet_controller = "5.3.1"
# wallet
grin_wallet_impls = { path = "wallet/impls" }
grin_wallet_api = { path = "wallet/api"}
grin_wallet_libwallet = { path = "wallet/libwallet" }
grin_wallet_util = { path = "wallet/util" }
grin_wallet_controller = { path = "wallet/controller" }
## ui
egui = { version = "0.28.1", default-features = false }
egui_extras = { version = "0.28.1", features = ["image", "svg"] }
rust-i18n = "2.3.1"
egui = { version = "0.33.3", default-features = false }
egui_extras = { version = "0.33.3", features = ["image", "svg"] }
egui-async = "0.3.4"
rust-i18n = "3.1.5"
## other
thiserror = "1.0.58"
futures = "0.3"
dirs = "5.0.1"
sys-locale = "0.3.0"
chrono = "0.4.31"
parking_lot = "0.12.1"
lazy_static = "1.4.0"
toml = "0.8.2"
serde = "1.0.170"
local-ip-address = "0.6.1"
url = "2.4.0"
rand = "0.8.5"
serde_derive = "1.0.197"
serde_json = "1.0.115"
tokio = { version = "1.37.0", features = ["full"] }
image = "0.25.1"
rqrr = "0.7.1"
log4rs = "1.4.0"
anyhow = "1.0.97"
pin-project = "1.1.10"
backtrace = "0.3.76"
thiserror = "2.0.18"
futures = "0.3.31"
dirs = "6.0.0"
sys-locale = "0.3.2"
chrono = "0.4.43"
parking_lot = "0.12.3"
lazy_static = "1.5.0"
toml = "0.9.11+spec-1.1.0"
serde = "1.0.228"
local-ip-address = "0.6.9"
url = "2.5.8"
rand = "0.9.2"
serde_derive = "1.0.228"
serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["full"] }
image = "0.25.9"
rqrr = "0.10.1"
qrcodegen = "1.8.0"
qrcode = "0.14.0"
qrcode = "0.14.1"
ur = "0.4.1"
gif = "0.13.1"
rkv = { version = "0.19.0", features = ["lmdb"] }
gif = "0.14.1"
rkv = "0.20.0"
usvg = "0.45.1"
ring = "0.16.20"
hyper = { version = "1.6.0", features = ["full"], package = "hyper" }
hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy", "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"
async-std = "1.13.2"
uuid = { version = "0.8.2", features = ["v4"] }
num-bigint = "0.4.6"
## tor
arti-client = { version = "0.19.0", features = ["pt-client", "static", "onion-service-service", "onion-service-client"] }
tor-rtcompat = { version = "0.19.0", features = ["static"] }
tor-config = "0.19.0"
fs-mistrust = "0.7.9"
tor-hsservice = "0.19.0"
tor-hsrproxy = "0.19.0"
tor-keymgr = "0.19.0"
tor-llcrypto = "0.19.0"
tor-hscrypto = "0.19.0"
arti-hyper = "0.19.0"
sha2 = "0.10.0"
ed25519-dalek = "2.1.1"
curve25519-dalek = "4.1.2"
hyper = { version = "0.14.28", features = ["full"] }
hyper-tls = "0.5.0"
tls-api = "0.9.0"
tls-api-native-tls = "0.9.0"
## nostr
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
nostr-relay-pool = "0.44"
## 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"
async-wsocket = "0.13"
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
regex = "1"
base64 = "0.22"
hex = "0.4"
## 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. 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"
## 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"
## stratum server
tokio-old = {version = "0.2", features = ["full"], package = "tokio" }
tokio-old = { version = "0.2", features = ["full"], package = "tokio" }
tokio-util-old = { version = "0.2", features = ["codec"], package = "tokio-util" }
[target.'cfg(all(not(target_os = "windows"), not(target_os = "android")))'.dependencies]
eye = { version = "0.5.0", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
nokhwa = { version = "0.10.10", default-features = false, features = ["input-v4l"] }
[target.'cfg(target_os = "windows")'.dependencies]
nokhwa = { version = "0.10.4", default-features = false, features = ["input-msmf"] }
nokhwa = { version = "0.10.10", default-features = false, features = ["input-msmf"] }
[target.'cfg(target_os = "macos")'.dependencies]
tls-api-openssl = "0.9.0"
openpnp_capture_sys = "0.4.0"
nokhwa = { version = "0.10.10", default-features = false, features = ["input-avfoundation", "output-threaded"] }
[target.'cfg(not(target_os = "android"))'.dependencies]
env_logger = "0.11.3"
winit = { version = "0.29.15" }
eframe = { version = "0.28.1", features = ["wgpu", "glow"] }
winit = { version = "0.30.12" }
wgpu = { version = "27.0.1" }
eframe = { version = "0.33.2", features = ["wgpu"] }
arboard = "3.2.0"
rfd = "0.14.1"
dark-light = "1.1.1"
rfd = "0.17.2"
interprocess = { version = "2.2.1", features = ["tokio"] }
## 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.13.1"
android_logger = "0.15.0"
jni = "0.21.1"
android-activity = { version = "0.6.0", features = ["game-activity"] }
wgpu = "0.20.1"
winit = { version = "0.29.15", features = ["android-game-activity"] }
eframe = { version = "0.28.1", features = ["wgpu", "android-game-activity"] }
winit = { version = "0.30.12", features = ["android-game-activity"] }
eframe = { version = "0.33.2", default-features = false, features = ["glow", "android-game-activity"] }
[patch.crates-io]
### patch grin store
#grin_store = { path = "../grin-store" }
### fix cross-compilation support for macos
openpnp_capture_sys = { git = "https://github.com/ardocrat/openpnp-capture-rs", branch = "cross_compilation_support" }
[build-dependencies]
built = "0.8.0"
# 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"] }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+57 -19
View File
@@ -1,38 +1,76 @@
# <img height="22" src="https://github.com/ardocrat/grim/blob/master/android/app/src/main/ic_launcher-playstore.png?raw=true"> Grim <img height="20" src="https://github.com/mimblewimble/site/blob/master/assets/images/grin-logo.png?raw=true"> <img height="20" src="https://github.com/ardocrat/grim/blob/master/img/logo.png?raw=true">
Cross-platform GUI for [GRiN ツ](https://grin.mw) in [Rust](https://www.rust-lang.org/)
for maximum compatibility with original [Mimblewimble](https://github.com/mimblewimble/grin) implementation.
Initially supported platforms are Linux, Mac, Windows, limited Android and possible web support with help of [egui](https://github.com/emilk/egui) - immediate mode GUI library in pure Rust.
<p align="center">
<img src="Goblin-Banner.png" alt="Goblin" width="680"/>
</p>
Named by the character [Grim](http://harrypotter.wikia.com/wiki/Grim) - the shape of a large, black, menacing, spectral giant dog.
# Goblin
![image](https://github.com/user-attachments/assets/a925b1c8-02c9-4b08-b888-0315d11138b6)
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 [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.
## Build instructions
### Install Rust
Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN node/wallet engine and layers a Nostr-native, mobile-first payments experience on top.
Follow instructions on [Windows](https://forge.rust-lang.org/infra/other-installation-methods.html).
## What it does
`curl https://sh.rustup.rs -sSf | sh`
- **Send to people** — pay a `username` or `npub`; the GRIN slatepack travels as a [NIP-17](https://nips.nostr.com/17) gift-wrapped DM ([kind 1059](https://nostrbook.dev/kinds/1059)) over 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.
- **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.
- **Cross-platform** — Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
### Desktop
To build and run application go to project directory and run:
## How a payment travels
```
you ──slatepack──▶ NIP-17 gift wrap (kind 1059, NIP-44 encrypted)
Tor
┌─────────────┴─────────────┐
your relays recipient's DM relays (kind 10050)
└─────────────┬─────────────┘
recipient ◀──unwrap, verify seal author, apply slatepack
```
The wrap is [NIP-44](https://nips.nostr.com/44)-encrypted, and delivery uses the recipient's DM relay list ([kind 10050](https://nostrbook.dev/kinds/10050)). 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**.
## Build
### 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/grim
./target/release/goblin
```
Goblin's identity and payment traffic — nostr relays, NIP-05 lookups and price fetches — rides Tor: the money-path relay is dialed directly at its pinned `.onion` address, and any relay without one (e.g. a recipient's arbitrary DM relay) is reached over a Tor exit to its 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
#### Set up the environment
Install Android SDK / NDK / Platform Tools for your OS according to this [FAQ](https://github.com/codepath/android_guides/wiki/installing-android-sdk-tools).
Install the Android SDK / NDK, then from the repo root:
#### Build the project
Run Android emulator or connect a real device. Command `adb devices` should show at least one device.
In the root of the repo run `./scripts/build_run_android.sh debug|release v7|v8`, where is `v7`, `v8` - device CPU architecture.
```
./scripts/android.sh build|release v7|v8|x86
```
`v7`/`v8`/`x86` is the device CPU architecture for `build`; for `release` pass a version in `major.minor.patch` form.
## Identity service (`goblin-nip05d`)
The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration and release (names are never transferred — on a key rotation you release the old name and re-register, or import your existing identity). The wallet is fully usable — and fully anonymous — without it. Avatars aren't stored or served — clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark).
## License
Apache License v2.0.
## Credits
🤖 Built with AI pair-programming assistance (Claude)
The underlying cross-platform GRIN wallet engine is the upstream **Grim** project.
+87 -25
View File
@@ -2,36 +2,56 @@ plugins {
id 'com.android.application'
}
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android {
compileSdk 33
ndkVersion '26.0.10792818'
compileSdk = 36
ndkVersion '29.0.14206865'
buildToolsVersion = '36.1.0'
defaultConfig {
applicationId "mw.gri.android"
applicationId "st.goblin.wallet"
minSdk 24
targetSdk 33
versionCode 1
versionName "0.1.0"
targetSdk 36
// 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
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
lint {
checkReleaseBuilds false
}
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
if (keystorePropertiesFile.exists()) {
signedRelease {
initWith release
signingConfig signingConfigs.release
}
}
debug {
minifyEnabled false
@@ -39,21 +59,63 @@ android {
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
namespace 'mw.gri.android'
flavorDimensions "mode"
productFlavors {
ci {
dimension "mode"
}
local {
dimension "mode"
}
}
applicationVariants.all { variant ->
def flavor = variant.productFlavors[0].name
// The ci branch reads the private-mirror properties at configuration time,
// which runs for every variant — only enter it when the mirror is configured.
if (flavor == "ci" && project.hasProperty("mavenHost")) {
repositories {
maven {
credentials {
username "$mavenUser"
password "$mavenPassword"
}
url "$mavenHost/repository/maven-central/"
allowInsecureProtocol = true
}
maven {
credentials {
username "$mavenUser"
password "$mavenPassword"
}
url "$mavenHost/repository/google-android-maven/"
allowInsecureProtocol = true
}
}
} else if (flavor == "local") {
repositories {
google()
mavenCentral()
}
}
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
// To use the Games Activity library
implementation "androidx.games:games-activity:2.0.2"
// Android Camera
implementation 'androidx.camera:camera-core:1.2.3'
implementation 'androidx.camera:camera-camera2:1.2.3'
implementation 'androidx.camera:camera-lifecycle:1.2.3'
}
implementation 'androidx.camera:camera-core:1.5.1'
implementation 'androidx.camera:camera-camera2:1.5.1'
implementation 'androidx.camera:camera-lifecycle:1.5.1'
}
+42 -23
View File
@@ -1,52 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
>
<manifest xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-permission android:name="android.permission.EXPAND_STATUS_BAR" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<application
android:hardwareAccelerated="true"
android:largeHeap="true"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Grim"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Main">
android:hardwareAccelerated="true"
android:largeHeap="true"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Goblin"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Main"
android:enableOnBackInvokedCallback="false"
android:extractNativeLibs="true"
tools:ignore="UnusedAttribute">
<receiver android:name=".NotificationActionsReceiver"/>
<provider
android:name=".FileProvider"
android:authorities="mw.gri.android.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
android:name=".FileProvider"
android:authorities="st.goblin.wallet.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/paths" />
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/paths" />
</provider>
<activity
android:launchMode="singleTask"
android:name=".MainActivity"
android:configChanges="orientation|screenSize"
android:exported="true">
android:launchMode="singleTask"
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode|density|locale|layoutDirection|fontScale|colorMode"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:scheme="http" tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="application/*" />
<data android:pathPattern=".*\\.slatepack" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="grim" />
</activity>
<service android:name=".BackgroundService" android:stopWithTask="true" />
<service
android:name=".BackgroundService"
android:stopWithTask="true"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -2,13 +2,13 @@ package mw.gri.android;
import android.annotation.SuppressLint;
import android.app.*;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.*;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import java.util.List;
@@ -16,13 +16,21 @@ import static android.app.Notification.EXTRA_NOTIFICATION_ID;
public class BackgroundService extends Service {
private static final String TAG = BackgroundService.class.getSimpleName();
private PowerManager.WakeLock mWakeLock;
private final Handler mHandler = new Handler(Looper.getMainLooper());
private boolean mStopped = false;
private static final int NOTIFICATION_ID = 1;
// 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 = "";
@@ -31,26 +39,6 @@ public class BackgroundService extends Service {
public static final String ACTION_START_NODE = "start_node";
public static final String ACTION_STOP_NODE = "stop_node";
public static final String ACTION_EXIT = "exit";
public static final String ACTION_REFRESH = "refresh";
public static final String ACTION_STOP = "stop";
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@SuppressLint("RestrictedApi")
@Override
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals(ACTION_STOP)) {
mStopped = true;
// Remove actions buttons.
mNotificationBuilder.mActions.clear();
NotificationManager manager = getSystemService(NotificationManager.class);
manager.notify(NOTIFICATION_ID, mNotificationBuilder.build());
} else {
mHandler.removeCallbacks(mUpdateSyncStatus);
mHandler.post(mUpdateSyncStatus);
}
}
};
private final Runnable mUpdateSyncStatus = new Runnable() {
@SuppressLint("RestrictedApi")
@@ -90,29 +78,18 @@ 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);
mNotificationBuilder.addAction(R.drawable.ic_stop, getStopText(), i);
}
// Set up a button to exit from the app.
if (canStart || canStop) {
Intent exitIntent = new Intent(BackgroundService.this, NotificationActionsReceiver.class);
if (Build.VERSION.SDK_INT > 25) {
exitIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID);
}
exitIntent.setAction(ACTION_EXIT);
PendingIntent i = PendingIntent
.getBroadcast(BackgroundService.this, 1, exitIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
mNotificationBuilder.addAction(R.drawable.ic_close, getExitText(), i);
}
}
// Update notification.
@@ -152,13 +129,17 @@ public class BackgroundService extends Service {
// Show notification with sync status.
Intent i = getPackageManager().getLaunchIntentForPackage(this.getPackageName());
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE);
mNotificationBuilder = new NotificationCompat.Builder(this, TAG)
.setContentTitle(this.getSyncTitle())
.setContentText(this.getSyncStatusText())
.setStyle(new NotificationCompat.BigTextStyle().bigText(this.getSyncStatusText()))
.setSmallIcon(R.drawable.ic_stat_name)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setContentIntent(pendingIntent);
try {
mNotificationBuilder = new NotificationCompat.Builder(this, TAG)
.setContentTitle(this.getSyncTitle())
.setContentText(this.getSyncStatusText())
.setStyle(new NotificationCompat.BigTextStyle().bigText(this.getSyncStatusText()))
.setSmallIcon(R.drawable.ic_stat_name)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setContentIntent(pendingIntent);
} catch (UnsatisfiedLinkError e) {
return;
}
Notification notification = mNotificationBuilder.build();
// Start service at foreground state to prevent killing by system.
@@ -166,9 +147,6 @@ public class BackgroundService extends Service {
// Update sync status at notification.
mHandler.post(mUpdateSyncStatus);
// Register receiver to refresh notifications by intent.
registerReceiver(mReceiver, new IntentFilter(ACTION_REFRESH));
}
@Override
@@ -199,7 +177,6 @@ public class BackgroundService extends Service {
// Stop updating the notification.
mHandler.removeCallbacks(mUpdateSyncStatus);
unregisterReceiver(mReceiver);
clearNotification();
// Remove service from foreground state.
@@ -221,13 +198,81 @@ 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 context) {
if (!isServiceRunning(context)) {
public static void start(Context c) {
if (!isServiceRunning(c)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(new Intent(context, BackgroundService.class));
ContextCompat.startForegroundService(c, new Intent(c, BackgroundService.class));
} else {
context.startService(new Intent(context, BackgroundService.class));
c.startService(new Intent(c, BackgroundService.class));
}
}
}
@@ -266,9 +311,6 @@ public class BackgroundService extends Service {
// Check if stop node is possible.
private native boolean canStopNode();
// Get exit text for notification.
private native String getExitText();
// Check if app from the app is needed after node stop.
private native boolean exitAppAfterNodeStop();
}
}
@@ -7,15 +7,15 @@ import android.content.*;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.*;
import android.os.Process;
import android.provider.Settings;
import android.system.ErrnoException;
import android.system.Os;
import android.util.Size;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
@@ -28,9 +28,11 @@ import androidx.core.view.DisplayCutoutCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.google.androidgamesdk.GameActivity;
import com.google.androidgamesdk.gametextinput.State;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.*;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -50,15 +52,13 @@ public class MainActivity extends GameActivity {
private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context ctx, Intent i) {
if (i.getAction().equals(STOP_APP_ACTION)) {
onExit();
Process.killProcess(Process.myPid());
if (Objects.equals(i.getAction(), STOP_APP_ACTION)) {
exit();
}
}
};
private final ImageAnalysis mImageAnalysis = new ImageAnalysis.Builder()
.setTargetResolution(new Size(640, 480))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build();
@@ -67,21 +67,33 @@ public class MainActivity extends GameActivity {
private ExecutorService mCameraExecutor = null;
private boolean mUseBackCamera = true;
private ActivityResultLauncher<Intent> mFilePickResultLauncher = null;
private ActivityResultLauncher<Intent> mFilePickResult = null;
private ActivityResultLauncher<Intent> mOpenFilePermissionsResult = null;
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
protected void onCreate(Bundle savedInstanceState) {
// Check if activity was launched to exclude from recent apps on exit.
if ((getIntent().getFlags() & Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) != 0) {
super.onCreate(null);
finish();
return;
}
// Clear cache on start.
if (savedInstanceState == null) {
if (savedInstanceState == null && getExternalCacheDir() != null) {
Utils.deleteDirectoryContent(new File(getExternalCacheDir().getPath()), false);
}
// Setup environment variables for native code.
try {
Os.setenv("HOME", getExternalFilesDir("").getPath(), true);
Os.setenv("XDG_CACHE_HOME", getExternalCacheDir().getPath(), true);
Os.setenv("HOME", Objects.requireNonNull(getExternalFilesDir("")).getPath(), true);
Os.setenv("XDG_CACHE_HOME", Objects.requireNonNull(getExternalCacheDir()).getPath(), true);
Os.setenv("ARTI_FS_DISABLE_PERMISSION_CHECKS", "true", true);
Os.setenv("NATIVE_LIBS_DIR", getApplicationInfo().nativeLibraryDir, true);
} catch (ErrnoException e) {
throw new RuntimeException(e);
}
@@ -89,17 +101,30 @@ public class MainActivity extends GameActivity {
super.onCreate(null);
// Register receiver to finish activity from the BackgroundService.
registerReceiver(mBroadcastReceiver, new IntentFilter(STOP_APP_ACTION));
ContextCompat.registerReceiver(this, mBroadcastReceiver, new IntentFilter(STOP_APP_ACTION), ContextCompat.RECEIVER_NOT_EXPORTED);
// Register file pick result launcher.
mFilePickResultLauncher = registerForActivityResult(
// Register associated file opening result.
mOpenFilePermissionsResult = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (Build.VERSION.SDK_INT >= 30) {
if (Environment.isExternalStorageManager()) {
onFile();
}
} else if (result.getResultCode() == RESULT_OK) {
onFile();
}
}
);
// Register file pick result.
mFilePickResult = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
int resultCode = result.getResultCode();
Intent data = result.getData();
if (resultCode == Activity.RESULT_OK) {
String path = "";
if (data != null) {
if (data != null && data.getData() != null) {
Uri uri = data.getData();
String name = "pick" + Utils.getFileExtension(uri, this);
File file = new File(getExternalCacheDir(), name);
@@ -107,11 +132,13 @@ public class MainActivity extends GameActivity {
OutputStream os = new FileOutputStream(file)) {
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) > 0) {
while (true) {
assert is != null;
if (!((length = is.read(buffer)) > 0)) break;
os.write(buffer, 0, length);
}
} catch (Exception e) {
e.printStackTrace();
Log.e("grim", e.toString());
}
path = file.getPath();
}
@@ -121,10 +148,36 @@ 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) -> {
// Setup cutouts values.
// Get display cutouts.
DisplayCutoutCompat dc = insets.getDisplayCutout();
int cutoutTop = 0;
int cutoutRight = 0;
@@ -140,7 +193,7 @@ public class MainActivity extends GameActivity {
// Get display insets.
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
// Setup values to pass into native code.
// Pass values into native code.
int[] values = new int[]{0, 0, 0, 0};
values[0] = Utils.pxToDp(Integer.max(cutoutTop, systemBars.top), this);
values[1] = Utils.pxToDp(Integer.max(cutoutRight, systemBars.right), this);
@@ -166,8 +219,65 @@ public class MainActivity extends GameActivity {
BackgroundService.start(this);
}
});
// Check if intent has data on launch.
if (savedInstanceState == null) {
onNewIntent(getIntent());
}
}
// Pass display insets into native code.
public native void onDisplayInsets(int[] cutouts);
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
String action = intent.getAction();
// Check if file was open with the application.
if (action != null && action.equals(Intent.ACTION_VIEW)) {
Intent i = getIntent();
i.setData(intent.getData());
setIntent(i);
onFile();
}
}
// Callback when associated file was open.
private void onFile() {
Uri data = getIntent().getData();
if (data == null) {
return;
}
if (Build.VERSION.SDK_INT >= 30) {
if (!Environment.isExternalStorageManager()) {
Intent i = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
mOpenFilePermissionsResult.launch(i);
return;
}
}
try {
ParcelFileDescriptor parcelFile = getContentResolver().openFileDescriptor(data, "r");
assert parcelFile != null;
FileReader fileReader = new FileReader(parcelFile.getFileDescriptor());
BufferedReader reader = new BufferedReader(fileReader);
String line;
StringBuilder buff = new StringBuilder();
while ((line = reader.readLine()) != null) {
buff.append(line);
}
reader.close();
fileReader.close();
// Provide file content into native code.
onData(buff.toString());
} catch (Exception e) {
Log.e("grim", e.toString());
}
}
// Pass data into native code.
public native void onData(String data);
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
@@ -184,83 +294,145 @@ public class MainActivity extends GameActivity {
if (results.length != 0 && results[0] == PackageManager.PERMISSION_GRANTED) {
switch (requestCode) {
case NOTIFICATIONS_PERMISSION_CODE: {
// Start notification service.
BackgroundService.start(this);
return;
}
case CAMERA_PERMISSION_CODE: {
// Start camera.
startCamera();
}
}
}
}
@Override
protected void onTextInputEventNative(long l, State state) {
super.onTextInputEventNative(l, state);
if (state.selectionEnd > state.composingRegionStart && state.composingRegionStart >= 0) {
String input = String.valueOf(state.text.charAt(state.composingRegionStart));
if (input.contains("\n")) {
onEnterInput();
} else {
onTextInput(input);
}
}
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
// To support non-english input.
if (event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
if (!event.getCharacters().isEmpty()) {
onInput(event.getCharacters());
if (event.getAction() == KeyEvent.ACTION_DOWN) {
if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
onBack();
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
onClearInput();
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) {
onEnterInput();
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_0) {
onTextInput("0");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_1) {
onTextInput("1");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_2) {
onTextInput("2");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_3) {
onTextInput("3");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_4) {
onTextInput("4");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_5) {
onTextInput("5");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_6) {
onTextInput("6");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_7) {
onTextInput("7");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_8) {
onTextInput("8");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_9) {
onTextInput("9");
return false;
}
// Pass any other input values into native code.
} else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
if (!event.getCharacters().isEmpty()) {
onTextInput(event.getCharacters());
return false;
}
// Pass any other input values into native code.
} else if (event.getAction() == KeyEvent.ACTION_UP &&
event.getKeyCode() != KeyEvent.KEYCODE_ENTER &&
event.getKeyCode() != KeyEvent.KEYCODE_BACK) {
onInput(String.valueOf((char)event.getUnicodeChar()));
onTextInput(String.valueOf((char)event.getUnicodeChar()));
return false;
}
return super.dispatchKeyEvent(event);
}
// Provide last entered character from soft keyboard into native code.
public native void onInput(String character);
// Implemented into native code to handle display insets change.
native void onDisplayInsets(int[] cutouts);
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
onBack();
return true;
}
return super.onKeyDown(keyCode, event);
}
// Implemented into native code to handle key code BACK event.
// Pass back navigation event into native code.
public native void onBack();
// Actions on app exit.
private void onExit() {
unregisterReceiver(mBroadcastReceiver);
BackgroundService.stop(this);
// Pass clear key event into native code.
public native void onClearInput();
// Pass enter key event into native code.
public native void onEnterInput();
// Pass last entered character from soft keyboard into native code.
public native void onTextInput(String character);
// Called from native code to exit app.
public void exit() {
finishAndRemoveTask();
}
@Override
protected void onDestroy() {
onExit();
unregisterReceiver(mBroadcastReceiver);
// Kill process after 3 seconds if app was terminated from recent apps to prevent app hanging.
new Thread(() -> {
try {
onTermination();
Thread.sleep(3000);
Process.killProcess(Process.myPid());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
// Only tear the process down when the activity is actually finishing.
// onDestroy also fires for configuration-change recreations (rotation,
// density, uiMode); killing the process there takes the whole app down
// right as Android is about to recreate the activity.
if (isFinishing()) {
BackgroundService.stop(this);
// Kill process after 3 secs if app was terminated from recent apps to prevent app hang.
new Thread(() -> {
try {
onTermination();
Thread.sleep(3000);
Process.killProcess(Process.myPid());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
}
// Destroy an app and kill process.
super.onDestroy();
Process.killProcess(Process.myPid());
}
// Notify native code to stop activity (e.g. node) if app was terminated from recent apps.
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);
@@ -271,45 +443,33 @@ public class MainActivity extends GameActivity {
// Called from native code to get text from clipboard.
public String pasteText() {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
String text;
ClipDescription desc = clipboard.getPrimaryClipDescription();
ClipData data = clipboard.getPrimaryClip();
String text = "";
if (!(clipboard.hasPrimaryClip())) {
text = "";
} else if (!(clipboard.getPrimaryClipDescription().hasMimeType(MIMETYPE_TEXT_PLAIN))
&& !(clipboard.getPrimaryClipDescription().hasMimeType(MIMETYPE_TEXT_HTML))) {
} else if (desc != null && (!(desc.hasMimeType(MIMETYPE_TEXT_PLAIN))
&& !(desc.hasMimeType(MIMETYPE_TEXT_HTML)))) {
text = "";
} else {
ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0);
} else if (data != null) {
ClipData.Item item = data.getItemAt(0);
text = item.getText().toString();
}
return text;
}
// Called from native code to show keyboard.
public void showKeyboard() {
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(getWindow().getDecorView(), InputMethodManager.SHOW_IMPLICIT);
}
// Called from native code to hide keyboard.
public void hideKeyboard() {
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(getWindow().getDecorView().getWindowToken(), 0);
}
// Called from native code to start camera.
public void startCamera() {
// Check permissions.
String notificationsPermission = Manifest.permission.CAMERA;
if (checkSelfPermission(notificationsPermission) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[] { notificationsPermission }, CAMERA_PERMISSION_CODE);
} else {
// Start .
if (mCameraProviderFuture == null) {
mCameraProviderFuture = ProcessCameraProvider.getInstance(this);
mCameraProviderFuture.addListener(() -> {
try {
mCameraProvider = mCameraProviderFuture.get();
// Launch camera.
// Start camera.
openCamera();
} catch (Exception e) {
View content = findViewById(android.R.id.content);
@@ -381,14 +541,85 @@ public class MainActivity extends GameActivity {
// Pass image from camera into native code.
public native void onCameraImage(byte[] buff, int rotation);
// Called from native code to share image from provided path.
public void shareImage(String path) {
// Called from native code to share data from provided path.
public void shareData(String path) {
File file = new File(path);
Uri uri = FileProvider.getUriForFile(this, "mw.gri.android.fileprovider", file);
Uri uri = FileProvider.getUriForFile(this, "st.goblin.wallet.fileprovider", file);
Intent intent = new Intent(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.setType("image/*");
startActivity(Intent.createChooser(intent, "Share image"));
intent.setType("text/*");
startActivity(Intent.createChooser(intent, "Share data"));
}
// Called from native code to SAVE a staged file to a user-chosen location.
// Launches the Storage Access Framework create-document picker; the result
// handler copies the staged source file into the chosen document.
public void saveFile(String path, String name) {
mPendingSavePath = path;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/octet-stream");
intent.putExtra(Intent.EXTRA_TITLE, name);
try {
mFileSaveResult.launch(intent);
} catch (android.content.ActivityNotFoundException ex) {
Log.e("grim", ex.toString());
mPendingSavePath = null;
}
}
// Called from native code to share plain text (e.g. a payment link) via the
// system share sheet.
public void shareText(String text) {
Intent intent = new Intent(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_TEXT, text);
intent.setType("text/plain");
startActivity(Intent.createChooser(intent, "Share"));
}
// Called from native code to play a short "error" haptic (rejected payment).
public void vibrateError() {
Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
if (vibrator == null || !vibrator.hasVibrator()) {
return;
}
// Two short pulses read as "no" / rejected, distinct from a tap.
long[] pattern = new long[]{0, 40, 70, 40};
if (Build.VERSION.SDK_INT >= 26) {
vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1));
} else {
vibrator.vibrate(pattern, -1);
}
}
// Called from native code to play a tiny "tick" haptic on a successful copy.
public void vibrateCopy() {
Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
if (vibrator == null || !vibrator.hasVibrator()) {
return;
}
// One short, light tick — a confirmation, not an alert.
if (Build.VERSION.SDK_INT >= 29) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK));
} else if (Build.VERSION.SDK_INT >= 26) {
vibrator.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE));
} else {
vibrator.vibrate(20);
}
}
// Called from native code to set status-bar icon color to contrast the
// in-app theme. white = light icons for a dark background. The app draws
// edge-to-edge, so the OS status-bar background is the app's own content;
// the icons must follow the theme or they vanish (dark-on-dark).
public void setStatusBarWhiteIcons(boolean white) {
runOnUiThread(() -> {
androidx.core.view.WindowInsetsControllerCompat c =
androidx.core.view.WindowCompat.getInsetsController(getWindow(),
getWindow().getDecorView());
// isAppearanceLightStatusBars == true means DARK icons.
c.setAppearanceLightStatusBars(!white);
});
}
// Called from native code to check if device is using dark theme.
@@ -400,9 +631,14 @@ public class MainActivity extends GameActivity {
// Called from native code to pick the file.
public void pickFile() {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
// 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 {
mFilePickResultLauncher.launch(Intent.createChooser(intent, "Pick file"));
mFilePickResult.launch(Intent.createChooser(intent, "Pick file"));
} catch (android.content.ActivityNotFoundException ex) {
onFilePick("");
}
@@ -4,23 +4,18 @@ import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import java.util.Objects;
public class NotificationActionsReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent i) {
String a = i.getAction();
if (a.equals(BackgroundService.ACTION_START_NODE)) {
if (Objects.equals(a, BackgroundService.ACTION_START_NODE)) {
startNode();
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH));
} else if (a.equals(BackgroundService.ACTION_STOP_NODE)) {
} else if (Objects.equals(a, BackgroundService.ACTION_STOP_NODE)) {
stopNode();
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH));
} else {
if (isNodeRunning()) {
stopNodeToExit();
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH));
} else {
context.sendBroadcast(new Intent(MainActivity.STOP_APP_ACTION));
}
stopNodeToExit();
}
}
@@ -30,6 +25,4 @@ public class NotificationActionsReceiver extends BroadcastReceiver {
native void stopNode();
// Stop node and exit from the app.
native void stopNodeToExit();
// Check if node is running.
native boolean isNodeRunning();
}
@@ -153,4 +153,4 @@ public class Utils {
String fileType = context.getContentResolver().getType(uri);
return MimeTypeMap.getSingleton().getExtensionFromMimeType(fileType);
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 984 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 B

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 27 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FCEF03</color>
<color name="ic_launcher_background">#FFD60A</color>
</resources>
@@ -3,6 +3,7 @@
<item name="android:statusBarColor">@color/yellow</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:navigationBarColor">@color/black</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="o_mr1">shortEdges</item>
</style>
</resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-cache-path name="images" path="images/" />
<external-cache-path name="share" path="share/" />
</paths>
+3 -8
View File
@@ -1,10 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.1.1' apply false
id 'com.android.library' version '8.1.1' apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}
id 'com.android.application' version '8.10.0' apply false
id 'com.android.library' version '8.10.0' apply false
}
+1 -2
View File
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
@@ -19,5 +19,4 @@ android.useAndroidX=true
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false
+1 -1
View File
@@ -1,6 +1,6 @@
#Mon May 02 15:39:12 BST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionUrl=https\://code.gri.mw/DEV/gradle/releases/download/v8.11.1/gradle-8.11.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+21 -11
View File
@@ -1,16 +1,26 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
// Private mirror only when its coordinates are supplied (-PmavenHost=… as in upstream CI);
// everyone else resolves plugins from the public repositories.
def mavenHost = providers.gradleProperty("mavenHost")
if (mavenHost.present) {
def mavenUser = providers.gradleProperty("mavenUser").get()
def mavenPassword = providers.gradleProperty("mavenPassword").get()
["gradle-plugin-portal", "google-maven", "maven-central"].each { repo ->
maven {
credentials {
username mavenUser
password mavenPassword
}
url "${mavenHost.get()}/repository/${repo}/"
allowInsecureProtocol = true
}
}
} else {
gradlePluginPortal()
google()
mavenCentral()
}
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
//rootProject.name = "Rust Template"
include ':app'
+88
View File
@@ -0,0 +1,88 @@
use std::env;
use std::path::PathBuf;
use std::process::Command;
/// The GRIM commit Goblin forked from; builds count commits on top of it.
const GOBLIN_FORK_BASE: &str = "b51a46b";
fn main() {
built::write_built_file().expect("Failed to acquire build-time information");
// Goblin versioning is build-based: Build N = commits since the fork.
// An explicit GOBLIN_BUILD env wins (CI builds from the public single-commit
// squash where the fork base isn't an ancestor, so the git count can't run);
// otherwise count commits since the fork; "dev" only as a last resort.
let build = env::var("GOBLIN_BUILD")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
Command::new("git")
.args([
"rev-list",
"--count",
&format!("{}..HEAD", GOBLIN_FORK_BASE),
])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| "dev".to_string());
println!("cargo:rustc-env=GOBLIN_BUILD={}", build);
// .git/HEAD only changes on branch switches; the reflog is appended on
// every commit, so the build number stays current.
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/logs/HEAD");
println!("cargo:rerun-if-env-changed=GOBLIN_BUILD");
// Setting up git hooks in the project: rustfmt and so on.
let git_hooks = format!(
"git config core.hooksPath {}",
PathBuf::from("./.hooks").to_str().unwrap()
);
if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", &git_hooks])
.output()
.expect("failed to execute git config for hooks");
} else {
Command::new("sh")
.args(["-c", &git_hooks])
.output()
.expect("failed to execute git config for hooks");
}
// Goblin'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() {}
+238
View File
@@ -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.
+377
View File
@@ -0,0 +1,377 @@
# Goblin transactions — how a payment works, end to end
This document explains the full lifecycle of a Goblin payment: how money moves,
every status it passes through, and the small guarantees that keep it safe. It is
written against the code in `src/nostr/` and `src/wallet/` — function names and
files are cited (line numbers drift, so they aren't).
---
## 1. The big picture: two layers
A Goblin payment is **a Grin transaction wrapped in a private nostr message**.
1. **Grin layer (the money).** Grin/Mimblewimble transactions are *interactive*:
the sender and recipient exchange a "slate" that passes through states until
it's finalized and posted on-chain. There are no addresses and no amounts on
the chain. Goblin reuses GRIM's full Grin node + wallet engine for this.
2. **Nostr layer (the delivery).** Instead of making you hand slate files back
and forth, Goblin delivers each slate as an **end-to-end-encrypted nostr
direct message**, routed through the **Nym mixnet**. You pay a `username` or
`npub`; the recipient's wallet applies the slate automatically.
The slate is the payload; nostr is the transport. Everything below is about how
those two layers move together and what state is tracked at each step.
### Slate states (Grin layer)
Interactive Grin slates pass through numbered states. Goblin uses two flows:
| Flow | States | Who builds what |
| --- | --- | --- |
| **Standard** (sender pushes money) | `Standard1``Standard2``Standard3` | Sender builds S1 (locks their outputs), recipient replies S2, sender finalizes S3 and posts |
| **Invoice** (recipient pulls money) | `Invoice1``Invoice2``Invoice3` | Requester builds I1 (the ask), payer replies I2 (pays), requester finalizes I3 and posts |
### Status + direction (Goblin's nostr metadata)
For each payment Goblin stores a `TxNostrMeta` (`src/nostr/types.rs`) keyed by
slate id, with a **direction** and a **status**:
`NostrTxDirection`:
- `Sent` — we pushed funds (we created S1).
- `Received` — we were paid (we replied S2).
- `RequestedByUs` — we issued an invoice (we created I1).
- `RequestedOfUs` — someone invoiced us and we paid it (we replied I2).
`NostrSendStatus`:
- `Created` — slate built locally, DM not dispatched yet (durable checkpoint).
- `AwaitingS2` — S1 sent, waiting for the recipient's S2 reply.
- `ReceivedNoReply` — we processed an incoming S1 (or I1) and built our reply, but haven't dispatched it yet (crash-recovery point).
- `RepliedS2` — our S2 reply was dispatched (we received a payment).
- `AwaitingI2` — our I1 invoice was sent, waiting for the payer's I2.
- `PaidAwaitingFinalize` — we paid an invoice (sent I2); the requester finalizes.
- `Finalized` — slate finalized and posted on-chain.
- `SendFailed` — DM dispatch failed; eligible for retry.
- `Cancelled` — cancelled locally (manual cancel or 24h expiry).
`Finalized` and `Cancelled` are **terminal**.
---
## 2. Identity & addressing
Your nostr identity is a key that is **deliberately not derived from your wallet
seed** (`src/nostr/identity.rs`) — so you can rotate it any time to stay
unlinkable without ever touching your funds. It's stored encrypted at rest
(NIP-49 ncryptsec under your wallet password).
You can optionally claim a human-readable **`username`** from a **name authority**
(a NIP-05 server). The authority is configurable (Settings → Identity → Name
authority; `NostrConfig::{nip05_server, home_domain, set_nip05_server}`), which is
what makes Goblin **federated**: a user on `bob@otherinstance.com` can pay
`alice@goblin.st`, because a full `name@domain` always resolves against that
domain's `/.well-known/nostr.json`. Bare names (`alice`) resolve against *your*
configured home authority.
Display rules (`data::display_name`, no `@` ever shown):
- A local **petname** wins.
- A verified name on **your home authority** shows bare (`alice`) + a check.
- A verified name on a **foreign authority** shows `alice · domain` + a check, so
it can never masquerade as a home name.
- Otherwise: a short npub.
Names are kept fresh: see [§11 Name freshness](#11-contacts--name-freshness).
---
## 3. Transport: NIP-17 gift wraps over Nym
A payment DM is built and sent by `send_payment_dm`; control messages (voids) by
`send_control_dm` (both in `src/nostr/client.rs`). The message structure
(`src/nostr/protocol.rs`):
- The **payload** is the raw Grin slatepack armor (`BEGINSLATEPACK… ENDSLATEPACK`)
inside a kind-14 rumor, prefixed with a human preamble (`[Goblin] GRIN payment
message — open in Goblin …`) so a non-Goblin nostr client shows something sane.
- **Tags:** a `["goblin","1"]` protocol marker always; an optional `["subject", …]`
carrying the user's note (sanitized); control DMs carry
`["goblin-action","void", slate_id]` and **no** slatepack.
- The rumor is sealed and wrapped as a **kind-1059 gift wrap** (NIP-59 + NIP-44
encryption) via nostr-sdk's `send_private_msg_to`. Relays only ever see
ciphertext — never the amount, sender, or recipient.
**Where it's delivered:** the recipient's **kind-10050 DM-relay list**
(`fetch_dm_relays`), with our own default relays as fallback, plus any relay
hints carried by a pasted `nprofile`. Default relays: `relay.goblin.st`,
`relay.damus.io`, `nos.lol` (`src/nostr/relays.rs`), capped at `MAX_DM_RELAYS`.
**How relays are reached:** every relay connection runs through the in-process
**Nym mixnet** SOCKS5 proxy (`NymWebSocketTransport`; `run_service` waits for the
proxy to be ready before dialing). The mixnet hides who-talks-to-whom at the
network layer. The Grin *node* connection (block sync + broadcasting the final tx)
is direct clearnet — it's public chain data, the same for everyone, not tied to
your identity.
The UI tracks an outgoing attempt via a coarse **send phase**
(`client::send_phase`): `IDLE / WORKING / SENT / FAILED / REQUEST_BLOCKED`, with a
human-readable failure reason on `FAILED`.
---
## 4. Flow A — Pay by username/npub (Standard, we send)
Dispatched as `WalletTask::NostrSend(amount, npub, note, relay_hints)`; handled in
`wallet.rs`.
1. `w.send(amount)` builds the **S1** slate and **locks our outputs** (the funds
are reserved but not yet spent).
2. **Save meta `Created`** *before* any network call — this is the durable point
a crash recovers from.
3. `send_payment_dm` delivers S1 → **`AwaitingS2`** (storing the gift-wrap event
id). On dispatch failure → **`SendFailed`** (retryable). On success the contact
is created/refreshed (so people you pay appear in Suggested) and a background
NIP-05 lookup resolves their name.
4. The recipient replies S2 (Flow B). When their S2 gift wrap arrives, the ingest
guard routes it to `nostr_finalize_post`, which finalizes **S3** and posts it
on-chain → **`Finalized`**.
```
Created ──(S1 sent)──▶ AwaitingS2 ──(their S2 arrives)──▶ Finalized
└──(dispatch fails)──▶ SendFailed ──(retry)──▶ AwaitingS2
└──(manual cancel / 24h expiry)──▶ Cancelled (outputs unlocked)
```
---
## 5. Flow B — Receiving (Standard, we're paid)
Our service subscribes to kind-1059 gift wraps addressed to us
(`run_service`). When an **S1** arrives, `handle_wrap` runs the ingest pipeline
(§7) and `decide()` classifies it by the **accept policy**:
- `Everyone`**AutoReceive** (auto-reply S2).
- `Contacts` → AutoReceive if the sender is a known contact, else **SurfaceIncoming** (an approval card).
- `Ask` → always SurfaceIncoming.
**AutoReceive:** `nostr_receive` builds the **S2** reply; we save meta
`Received` / **`ReceivedNoReply`**, mark the message processed, then dispatch S2 →
**`RepliedS2`**. If the S2 dispatch fails we stay at `ReceivedNoReply` and resend
on the next start (§9). The sender then finalizes S3 (Flow A step 4).
```
(incoming S1) ──▶ ReceivedNoReply ──(S2 dispatched)──▶ RepliedS2
└──(dispatch fails)──▶ stays ReceivedNoReply → resent on restart
```
**SurfaceIncoming** instead stores a `PaymentRequest` (status `Pending`) for the
user to approve or decline — see Flow D.
---
## 6. Flow C — Request money (Invoice)
**We request**`WalletTask::NostrRequest(amount, npub, note, …)`:
1. First we check the recipient hasn't opted out: `accepts_requests` reads their
kind-0 `goblin_accepts_requests` field; an explicit `false` → phase
`REQUEST_BLOCKED` and we stop (fail-open: unknown/unreachable = allowed).
2. `issue_invoice(amount)` builds **I1** (no outputs locked — it's just an ask).
3. Save meta `RequestedByUs / Created`, dispatch I1 → **`AwaitingI2`**.
4. When the payer's **I2** arrives, the ingest guard finalizes **I3** and posts →
**`Finalized`**.
**They approve & pay** (the other side of the same flow) is Flow D.
```
Created ──(I1 sent)──▶ AwaitingI2 ──(their I2 arrives)──▶ Finalized
└──(SendFailed → retry) └──(cancel / expiry)──▶ Cancelled
```
---
## 7. Flow D — Approving an incoming request (we pay an invoice)
Someone's **I1** is *always* surfaced as a `PaymentRequest`, **never auto-paid**
(a hard security invariant). It shows in the Requests list. The user can:
- **Approve** → `WalletTask::NostrPayRequest(rumor_id)`: re-parse the stored
slatepack (must still be I1), `nostr_pay` builds **I2** (this is where *we* pay),
save meta `RequestedOfUs / ReceivedNoReply`, dispatch I2 → **`PaidAwaitingFinalize`**,
mark the request `Approved`. The requester then finalizes I3.
A "Paying…" spinner shows while this runs; the card clears on success.
- **Decline** → `WalletTask::NostrDeclineRequest(rumor_id)`: mark `Declined` and
send a **void** control DM so the requester's side clears too.
A surfaced incoming *Standard* S1 (from SurfaceIncoming) is approved the same way,
but routes through `nostr_receive` (Flow B) rather than `nostr_pay`.
`RequestStatus`: `Pending → Approved | Declined | Expired | Cancelled`
(`Cancelled` = the requester withdrew it via a void).
---
## 8. The ingest guard — `decide()`
Every incoming gift wrap is judged by `decide()` (`src/nostr/ingest.rs`), a
**positive allow-list**: anything not explicitly accepted is `Drop`ped. This is
the security core. Summary:
| Incoming state | Condition | Decision |
| --- | --- | --- |
| `Standard1` | amount 0, or slate already known | **Drop** |
| `Standard1` | new, policy `Everyone` (or `Contacts` + known) | **AutoReceive** |
| `Standard1` | new, policy `Contacts` + unknown, or `Ask` | **SurfaceIncoming** |
| `Standard2` | matches our pending `Sent` tx (status `AwaitingS2/Created/SendFailed`) **and** sender == stored counterparty **and** the tx exists | **FinalizePost** |
| `Standard2` | sender mismatch, or status `Cancelled`/`Finalized`, or no meta | **Drop** |
| `Invoice1` | amount 0, already known, or incoming-requests disabled | **Drop** |
| `Invoice1` | otherwise | **SurfaceRequest** (never auto-pay) |
| `Invoice2` | matches our pending `RequestedByUs` tx + sender match | **FinalizePost** |
| `Invoice3` / unknown | — | **Drop** |
Key consequences:
- A **late S2 on a cancelled send** falls through to `Drop` — so cancelling is
safe even if the recipient's reply is still in flight (the cancel marks the meta
`Cancelled` *first*, and `decide()` then drops the S2).
- Finalize only happens for a slate we are actually waiting on, from the exact key
we sent to.
- Invoices are never auto-paid.
---
## 9. Cancel & reclaim
A stuck outgoing send (recipient never replied) locks your outputs. You can
reclaim them manually from the receipt's **"Cancel payment"** button, or the 24h
auto-expiry does it for you (§10).
`WalletTask::NostrCancelSend(slate_id)` (`wallet.rs`):
1. Take the `cancel_finalize_lock` — this **serializes against a concurrent S2
finalize** so the two can't both win (cancel-and-post would be a double action).
2. **Re-check live state under the lock.** If the tx is already `Finalized`, or
confirmed/posted on-chain → do nothing and return `CancelOutcome::AlreadyCompleted`
("This payment already went through and can't be cancelled"). If already
`Cancelled` → idempotent success.
3. Otherwise mark the meta **`Cancelled` first**, then `w.cancel(tx_id)` to unlock
the Grin outputs, then best-effort send a **void** control DM to the recipient
(they're likely offline). → `CancelOutcome::Cancelled` ("your funds are
available again").
**Receipt button visibility** (`cancelable_send` gate): shown only while the send
is still unanswered — direction `Sent`, status in `{Created, AwaitingS2,
SendFailed}`, **not** confirmed, **not** already cancelled, and either it never
reached a relay (`SendFailed`, shown immediately) or the grace window
(`cancel_grace_secs`, default 600s) has passed. The instant the recipient accepts
(status leaves that set) the button disappears.
**Recipient side / void ordering:** a void control message marks the slate so that
if the recipient's wallet later (or earlier) sees the S1, it's dropped — including
the **void-before-S1** race, where the void arrives first and is recorded as
`void:{slate_id}:{sender}` so the subsequent S1 is dropped.
There are sibling tasks for the other directions: `NostrCancelOutgoing` (withdraw
an invoice we issued) and `NostrDeclineRequest` (decline an incoming request) —
both send a void and mark the local record.
---
## 10. Auto-expiry (24h)
`expire_stale` (`src/nostr/client.rs`) runs from the sync loop. Any non-terminal
meta older than `expiry_secs` (default 24h) is expired:
- If it **locked our outputs** (`expiry_locks_outputs`: a `Sent` send in
`Created/AwaitingS2/SendFailed`, or a `RequestedOfUs` invoice we paid in
`PaidAwaitingFinalize`) → cancel the Grin tx to unlock, and mark meta `Cancelled`.
- If it locked nothing of ours (incoming payments, invoices we issued) → just
annotate `Cancelled`.
- Pending incoming `PaymentRequest`s flip to `Expired`.
This is the same unlock path as manual cancel; the manual button just lets you act
before the 24h.
---
## 11. Crash recovery (`reconcile`)
On service start, `reconcile` (`client.rs`) re-dispatches any pending outgoing
message within a 7-day window, by `(direction, status)`:
| Direction · status | Slate | Action |
| --- | --- | --- |
| `Sent` · `Created`/`SendFailed` | Standard1 | resend S1 → `AwaitingS2` |
| `RequestedByUs` · `Created`/`SendFailed` | Invoice1 | resend I1 → `AwaitingI2` |
| `Received` · `ReceivedNoReply` | Standard2 | resend S2 → `RepliedS2` |
| `RequestedOfUs` · `ReceivedNoReply` | Invoice2 | resend I2 → `PaidAwaitingFinalize` |
Because the slatepack text is persisted and the meta is written *before* every
dispatch, a crash at any point is recoverable: re-sending an already-delivered
message is harmless (the peer dedups it; see §12).
---
## 12. Confirmations (X / N)
A posted Grin tx matures over `min_confirmations` blocks (default 10) before it's
spendable. Grin marks a tx `confirmed` at the **first** block, but Goblin's
receipt counts toward the spendable threshold so the number actually moves
(`data::receipt_detail`):
- broadcast, no block yet → `0 / N`
- on-chain, immature → `count / N` where `count = tip inclusion_height + 1`
- `count ≥ N` → matured (shown as complete; the receipt's network-fee row is shown
only for outgoing payments — a recipient pays no fee).
---
## 13. Reliability primitives
- **Dedup / processed markers** (`store::{is_processed, mark_processed,
prune_processed}`): every wrap is recorded at three levels — the gift-wrap event
id, the inner rumor id, and `slate:{id}:{state}` — so a replayed or re-sent
message is processed exactly once. Markers TTL out after 30 days
(pruned on start + hourly).
- **Rate limiting** (`allow_sender`): per-sender sliding window — 30 events/hour
for known contacts, 10/hour for unknowns — plus a global decrypt ceiling
(~120 NIP-44 unwraps/min) to bound CPU/battery against fresh-keypair spam. A
message dropped purely for the *global* ceiling isn't marked processed, so it can
be retried later.
- **Seal integrity:** the gift-wrap seal signer must equal the inner rumor author,
and self-addressed messages are dropped.
- **The cancel/finalize lock** (§9) prevents a cancel and a finalize from both
succeeding on the same slate.
---
## 14. Name freshness (contacts)
Cached `@usernames` are re-validated against the name authority on a periodic
sweep (`NAME_REVERIFY_INTERVAL_SECS`, ~78s, capped per tick), and once at app open
(persisted `last_name_sweep_at`, gated to the interval).
`nip05::check` returns `Verified / Mismatch / Unreachable`: a name is only
**cleared** (falls back to the npub) on a definitive `Mismatch` (the server says
it's gone or now maps to a different key) — never on a transient network failure.
This catches released or reassigned names and stops a freed name from
impersonating someone. A user-set petname is never touched.
---
## 15. File map
| Concern | File |
| --- | --- |
| Status / direction / meta types | `src/nostr/types.rs` |
| Gift-wrap + control message build/parse | `src/nostr/protocol.rs` |
| Service loop, send/receive/finalize, expiry, reconcile, name sweep | `src/nostr/client.rs` |
| Ingest allow-list | `src/nostr/ingest.rs` |
| Wallet task handlers (NostrSend / Request / PayRequest / CancelSend / finalize) | `src/wallet/wallet.rs` |
| Task definitions | `src/wallet/types.rs` |
| Metadata + dedup + contacts store | `src/nostr/store.rs` |
| NIP-05 resolve / verify / register, name authority | `src/nostr/nip05.rs` |
| Identity (key, NIP-49 backup) | `src/nostr/identity.rs` |
| Receipt / activity / confirmations / display name | `src/gui/views/goblin/data.rs` |
| Relay defaults + name-authority defaults | `src/nostr/relays.rs` |
---
🤖 Documentation written with AI pair-programming assistance (Claude).
+112
View File
@@ -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)))),
)
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+48
View File
@@ -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

+48
View File
@@ -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

+48
View File
@@ -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

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 37 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+1
View File
@@ -0,0 +1 @@
goblin.png
+7
View File
@@ -0,0 +1,7 @@
[Desktop Entry]
Name=Goblin
Exec=goblin
Icon=goblin
Type=Application
Categories=Finance
MimeType=application/x-slatepack;text/plain;
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

-1
View File
@@ -1 +0,0 @@
grim.png
-6
View File
@@ -1,6 +0,0 @@
[Desktop Entry]
Name=Grim
Exec=grim
Icon=grim
Type=Application
Categories=Finance
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

+56 -20
View File
@@ -1,27 +1,63 @@
#!/bin/bash
#
# Build a portable, single-file Goblin AppImage.
#
# Usage: linux/build_release.sh [platform]
# platform: 'x86_64' (default) or 'arm'
#
# 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.
case $2 in
x86_64|arm)
;;
*)
echo "Usage: release_linux.sh [version] [platform]\n - platform: 'x86_64', 'arm'" >&2
exit 1
set -euo pipefail
platform="${1:-x86_64}"
case "${platform}" in
x86_64) arch="x86_64-unknown-linux-gnu"; 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
# Setup build directory
BASEDIR=$(cd $(dirname $0) && pwd)
cd ${BASEDIR}
cd ..
# Repo root (this script lives in linux/).
BASEDIR=$(cd "$(dirname "$0")" && pwd)
cd "${BASEDIR}/.."
# Setup platform argument
[[ $2 == "x86_64" ]] && arch+=(x86_64-unknown-linux-gnu)
[[ $2 == "arm" ]] && arch+=(aarch64-unknown-linux-gnu)
# Prefer the GRIM-canonical toolchains (zig + appimagetool from code.gri.mw/DEV);
# scripts/toolchain.sh fetches them and writes this env. Falls back to system
# installs when it's absent.
[ -f .toolchains/env.sh ] && source .toolchains/env.sh
# Start release build with zig linker for cross-compilation
cargo install cargo-zigbuild
cargo zigbuild --release --target ${arch}
rustup target add "${arch}"
command -v cargo-zigbuild >/dev/null || cargo install cargo-zigbuild
# Create AppImage with https://github.com/AppImage/appimagetool
cp target/${arch}/release/grim linux/Grim.AppDir/AppRun
rm target/${arch}/release/*.AppImage
appimagetool linux/Grim.AppDir target/${arch}/release/grim-v$1-linux-$2.AppImage
# Portable cross-build to glibc 2.17. Three zig-specific fixes:
# - CRoaring's AVX512 path won't compile under zig's clang (evex512 error).
# - OpenSSL is vendored in Cargo.toml, so no system libssl is needed.
# - v4l2-sys (camera/QR backend) runs bindgen over linux/videodev2.h, a kernel
# UAPI header missing from zig 0.12.1's glibc-2.17 sysroot; point bindgen at
# the host's kernel headers. This only reads struct layouts — the actual libc
# linkage stays glibc-2.17, so portability is unaffected.
export CFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0"
export CXXFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0"
export BINDGEN_EXTRA_CLANG_ARGS="${BINDGEN_EXTRA_CLANG_ARGS:-} -I/usr/include"
cargo zigbuild --release --target "${arch}.2.17"
# Assemble the AppDir: AppRun IS the goblin binary (Tor/arti linked in), plus the
# icon + desktop entry. Nothing else.
appdir="linux/Goblin.AppDir"
cp "target/${arch}/release/goblin" "${appdir}/AppRun"
chmod +x "${appdir}/AppRun"
out="target/${arch}/release/Goblin-${platform}.AppImage"
rm -f "target/${arch}/release/"*.AppImage
# Use the DEV appimagetool + type2 runtime when fetched, else the system tool.
appimagetool_bin="${GOBLIN_APPIMAGETOOL:-appimagetool}"
# 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 "${runtime_file}" ] && [ -e "${runtime_file}" ] && runtime_arg=(--runtime-file "${runtime_file}")
ARCH="${appimage_arch}" "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
echo "built: ${out}"
+536 -6
View File
@@ -25,13 +25,26 @@ share: teilen
theme: 'Theme:'
dark: Dunkel
light: Hell
file: Datei
choose_file: Datei auswählen
choose_folder: Ordner auswählen
crash_report: Absturzbericht
crash_report_warning: Anwendung wurde beim letzten Mal unerwartet geschlossen, Sie können den Absturzbericht mit Entwicklern teilen.
confirmation: Bestätigung
enter_url: URL eingeben
max_short: MAX
files_location: Dateistandort
moving_files: Dateien verschieben
wrong_path_error: Falscher Weg angegeben
check_updates: Suchen Sie beim Start nach Updates
update_available: Update ist verfügbar!
changelog: 'Wechselbuch:'
wallets:
await_conf_amount: Erwarte Bestätigung
await_fin_amount: Warten auf die Fertigstellung
locked_amount: Gesperrt
txs_empty: 'Um Geld manuell oder per Transport zu empfangen oder zu senden, verwenden Sie die Schaltflächen %{message} oder %{transport} unten auf dem Bildschirm. Um die Wallet-Einstellungen zu ändern, drücken Sie %{settings}.'
title: Wallets
title: Goblin
create_desc: Erstellen oder importieren Sie ein bestehendes Wallet mit dem Seed-Phrase.
add: Wallet hinzufügen
name: 'Name:'
@@ -80,6 +93,7 @@ wallets:
tx_canceled: Abgebrochen
tx_cancelling: Abbrechen
tx_finalizing: Finalisierung
tx_posting: Buchungsvorgang
tx_confirmed: Bestätigt
txs: Transaktionen
tx: Transaktion
@@ -123,6 +137,12 @@ wallets:
tx_receive_cancel_conf: 'Sind Sie sicher, dass Sie das Empfangen von %{amount} ツ abbrechen wollen?'
rec_phrase_not_found: Wiederhestellungsphrase nicht gefunden.
restore_wallet_desc: Stellen Sie das Wallet wieder her, indem Sie alle Dateien löschen. Wenn die normale Reparatur nicht geholfen hat, müssen Sie Ihr Wallet erneut öffnen.
fee_base_desc: 'Gebühr (basiswert%{value}):'
payment_proof: Zahlungsnachweis
payment_proof_desc: 'Geben Sie den erhaltenen Zahlungsnachweis ein, um die Transaktion zu verifizieren:'
payment_proof_valid: 'Der eingegebene Zahlungsnachweis ist gültig:'
payment_proof_error: 'Der eingetragene Zahlungsnachweis ist nicht gültig:'
tx_delete_confirmation: Bist du sicher, dass du die Transaktion aus dem Verlauf löschen möchtest?
transport:
desc: 'Transport verwenden, um Nachrichten synchron zu empfangen oder zu senden:'
tor_network: Tor Netzwek
@@ -135,7 +155,7 @@ transport:
incorrect_addr_err: 'Eingegebene Addresse ist inkorrekt:'
tor_send_error: Beim Senden über Tor ist ein Fehler aufgetreten. Stellen Sie sicher, dass der Empfänger online ist. Die Transaktion wurde abgebrochen.
tor_autorun_desc: Gibt an, ob beim Öffnen des Wallets der Tor-Dienst gestartet werden soll, um Transaktionen synchron zu empfangen.
tor_sending: 'Sende %{amount} ツ über Tor'
tor_sending: Sende über Tor
tor_settings: Tor Einstellungen
bridges: Brücken
bridges_desc: Richten Sie Brücken ein, um die Zensur des Tor-Netzwerks zu umgehen, wenn die normale Verbindung nicht funktioniert.
@@ -280,13 +300,523 @@ network_settings:
ban_window_desc: Die Entscheidung über das Verbot trifft der Knoten auf der Grundlage der Korrektheit der von der Gegenstelle erhaltenen Daten.
max_inbound_count: 'Maximale Anzahl der eingehenden Peer-Verbindungen:'
max_outbound_count: 'Maximale Anzahl von ausgehenden Peer-Verbindungen:'
reset_peers_desc: Peer-Daten zurücksetzen. Verwenden Sie diese Funktion nur, wenn es Probleme beim finden von Peers gibt.
reset_peers: Peers zurücksetzten
reset_data_desc: Reset-Knotendaten. Verwenden Sie diese Funktion nur, wenn es Probleme mit der Synchronisation gibt.
reset_data: Daten zurücksetzten
modal:
cancel: Abbrechen
save: Speichern
confirmation: Bestätigung
add: Hinzufügen
modal_exit:
description: Sind Sie sicher, dass Sie die Anwendung beenden wollen?
exit: Schließen
exit: Schließen
app_settings:
proxy: Proxy
proxy_desc: Lohnt es sich, einen Proxy für Netzwerkanfragen von der Anwendung zu verwenden.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: ß
q: q
w: w
e: e
r: r
t: t
y: z
u: u
i: i
o: o
p: p
p1: ü
a: a
s: s
d: d
f: f
g: g
h: h
j: j
k: k
l: l
l1: ö
l2: ä
z: y
x: x
c: c
v: v
b: b
n: n
m: m
m1: ','
m2: .
m3: '/'
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…"
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"
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"
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 320 Zeichen: az, 09, _ 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: "usernames"
usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st."
price_avatars: "Preis"
price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen."
over_mixnet: "Über 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_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"
+538 -8
View File
@@ -25,18 +25,31 @@ share: Share
theme: 'Theme:'
dark: Dark
light: Light
file: File
choose_file: Choose file
choose_folder: Choose folder
crash_report: Crash report
crash_report_warning: Application closed unexpectedly last time, you can share crash report with developers.
confirmation: Confirmation
enter_url: Enter URL
max_short: MAX
files_location: Files location
moving_files: Moving files
wrong_path_error: Wrong path specified
check_updates: Check for updates at startup
update_available: Update is available!
changelog: 'Changelog:'
wallets:
await_conf_amount: Awaiting confirmation
await_fin_amount: Awaiting finalization
locked_amount: Locked
txs_empty: 'To receive funds manually or over transport use %{message} or %{transport} buttons at the bottom of the screen, to change wallet settings press %{settings} button.'
title: Wallets
title: Goblin
create_desc: Create or import existing wallet from saved recovery phrase.
add: Add wallet
name: 'Name:'
pass: 'Password:'
pass_empty: Enter password from the wallet
pass_empty: Enter the wallet password
current_pass: 'Current password:'
new_pass: 'New password:'
min_tx_conf_count: 'Minimum amount of confirmations for transactions:'
@@ -80,6 +93,7 @@ wallets:
tx_canceled: Canceled
tx_cancelling: Cancelling
tx_finalizing: Finalizing
tx_posting: Posting
tx_confirmed: Confirmed
txs: Transactions
tx: Transaction
@@ -123,6 +137,12 @@ wallets:
tx_receive_cancel_conf: 'Are you sure you want to cancel receiving of %{amount} ツ?'
rec_phrase_not_found: Recovery phrase not found.
restore_wallet_desc: Restore wallet by deleting all files if usual repair not helped, you will need to re-open your wallet.
fee_base_desc: 'Fee (base value%{value}):'
payment_proof: Payment proof
payment_proof_desc: 'Enter received payment proof to verify transaction:'
payment_proof_valid: 'Entered payment proof is valid:'
payment_proof_error: 'Entered payment proof is not valid:'
tx_delete_confirmation: Are you sure you want to delete the transaction from history?
transport:
desc: 'Use transport to receive or send messages synchronously:'
tor_network: Tor network
@@ -135,7 +155,7 @@ transport:
incorrect_addr_err: 'Entered address is incorrect:'
tor_send_error: An error occurred during sending over Tor, make sure receiver is online, transaction was canceled.
tor_autorun_desc: Whether to launch Tor service on wallet opening to receive transactions synchronously.
tor_sending: 'Sending %{amount} ツ over Tor'
tor_sending: Sending over Tor
tor_settings: Tor Settings
bridges: Bridges
bridges_desc: Setup bridges to bypass Tor network censorship if usual connection is not working.
@@ -160,7 +180,7 @@ network:
available: Available
not_available: Not available
availability_check: Availability check
android_warning: Attention to Android users. To synchronize integrated node successfully, you must allow access to notifications and remove battery usage restrictions for the Grim application at system settings of your phone. This is necessary operation for correct work of application in the background.
android_warning: Attention to Android users. To synchronize integrated node successfully, you must allow access to notifications and remove battery usage restrictions for the Goblin application at system settings of your phone. This is necessary operation for correct work of application in the background.
sync_status:
node_restarting: Node is restarting
node_down: Node is down
@@ -280,13 +300,523 @@ network_settings:
ban_window_desc: The decision to ban is made by node, based on the correctness of the data received from the peer.
max_inbound_count: 'Maximum number of inbound peer connections:'
max_outbound_count: 'Maximum number of outbound peer connections:'
reset_peers_desc: Reset peers data. Use it with a caution only if there are problems with finding peers.
reset_peers: Reset peers
reset_data_desc: Reset the node data. Use it with a caution only if there are problems with synchronization.
reset_data: Reset data
modal:
cancel: Cancel
save: Save
confirmation: Confirmation
add: Add
modal_exit:
description: Are you sure you want to quit the application?
exit: Exit
exit: Exit
app_settings:
proxy: Proxy
proxy_desc: Whether to use proxy for network requests from the application.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '-'
q: q
w: w
e: e
r: r
t: t
y: y
u: u
i: i
o: o
p: p
p1: '"'
a: a
s: s
d: d
f: f
g: g
h: h
j: j
k: k
l: l
l1: \
l2: ':'
z: z
x: x
c: c
v: v
b: b
n: n
m: m
m1: ','
m2: .
m3: /
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…"
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"
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"
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 320 chars: az, 09, _ 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_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"
+536 -6
View File
@@ -25,13 +25,26 @@ share: Partager
theme: 'Thème:'
dark: Sombre
light: Clair
file: Fichier
choose_file: Choisir un fichier
choose_folder: Choisir un dossier
crash_report: Rapport d'échec
crash_report_warning: L'application s'est fermée de manière inattendue la dernière fois, vous pouvez partager un rapport d'incident avec les développeurs.
confirmation: Confirmation
enter_url: Entrez l'URL
max_short: MAX
files_location: Emplacement du fichier
moving_files: Déplacer des fichiers
wrong_path_error: Chemin incorrect spécifié
check_updates: Vérifiez les mises à jour au démarrage
update_available: Mise à jour disponible!
changelog: 'Journal des modifications:'
wallets:
await_conf_amount: En attente de confirmation
await_fin_amount: En attente de finalisation
locked_amount: Verrouillé
txs_empty: "Pour recevoir des fonds manuellement ou par transport, utilisez les boutons %{message} ou %{transport} en bas de l'écran. Pour modifier les paramètres du portefeuille, appuyez sur le bouton %{settings}."
title: 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:'
@@ -80,6 +93,7 @@ wallets:
tx_canceled: Annulé
tx_cancelling: Annulation
tx_finalizing: Finalisation
tx_posting: Publication
tx_confirmed: Confirmé
txs: Transactions
tx: Transaction
@@ -123,6 +137,12 @@ wallets:
tx_receive_cancel_conf: 'Êtes-vous sûr de vouloir annuler la réception de %{amount} ツ?'
rec_phrase_not_found: Phrase de récupération non trouvée.
restore_wallet_desc: "Restaurer le portefeuille en supprimant tous les fichiers si la réparation habituelle n'a pas aidé. Vous devrez rouvrir votre portefeuille."
fee_base_desc: 'Frais (valeur de base%{value}):'
payment_proof: Preuve de paiement
payment_proof_desc: 'Saisissez la preuve de paiement reçue pour vérifier la transaction:'
payment_proof_valid: 'La preuve de paiement saisie est valide:'
payment_proof_error: "La preuve de paiement saisie n'est pas valide:"
tx_delete_confirmation: Êtes-vous sûr de vouloir supprimer la transaction de l'historique?
transport:
desc: 'Utilisez le transport pour recevoir ou envoyer des messages de manière synchronisée:'
tor_network: Réseau Tor
@@ -135,7 +155,7 @@ transport:
incorrect_addr_err: 'Adresse entrée incorrecte:'
tor_send_error: "Une erreur s'est produite lors de l'envoi via Tor. Assurez-vous que le destinataire est en ligne, la transaction a été annulée."
tor_autorun_desc: "Lancer automatiquement le service Tor à l'ouverture du portefeuille pour recevoir les transactions de manière synchronisée."
tor_sending: 'Envoi de %{amount} ツ via Tor'
tor_sending: Envoi via Tor
tor_settings: Paramètres Tor
bridges: Passerelles
bridges_desc: Configurez des passerelles pour contourner la censure du réseau Tor si la connexion habituelle ne fonctionne pas.
@@ -280,13 +300,523 @@ network_settings:
ban_window_desc: La décision de bannir est prise par le noeud, en fonction de la validité des données reçues du pair.
max_inbound_count: 'Nombre maximum de connexions de pairs entrants :'
max_outbound_count: 'Nombre maximum de connexions de pairs sortants :'
reset_peers_desc: Réinitialiser les données des pairs. Utilisez-le avec précaution uniquement en cas de problèmes pour trouver des pairs.
reset_peers: Réinitialiser les pairs
reset_data_desc: Réinitialisez les données du noeud. Utilisez-le avec prudence uniquement en cas de problème de synchronisation.
reset_data: Réinitialisation des données
modal:
cancel: Annuler
save: Sauvegarder
confirmation: Confirmation
add: Ajouter
modal_exit:
description: "Êtes-vous sûr de vouloir quitter l'application ?"
exit: Quitter
exit: Quitter
app_settings:
proxy: Proxy
proxy_desc: Vaut-il la peine d'utiliser un proxy pour les requêtes réseau de l'application.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '`'
q: a
w: z
e: e
r: r
t: t
y: y
u: u
i: i
o: o
p: p
p1: ç
a: q
s: s
d: d
f: f
g: g
h: h
j: j
k: k
l: l
l1: m
l2: ù
z: w
x: x
c: c
v: v
b: b
n: n
m: ','
m1: .
m2: ':'
m3: /
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…"
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é"
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"
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 : az, 09, _ 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: "usernames"
usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st."
price_avatars: "Prix"
price_avatars_blurb: "Le taux en temps réel affiché à côté des montants."
over_mixnet: "Via 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_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"
+536 -6
View File
@@ -25,13 +25,26 @@ share: Поделиться
theme: 'Тема:'
dark: Тёмная
light: Светлая
file: Файл
choose_file: Выбрать файл
choose_folder: Выбрать папку
crash_report: Отчёт о сбое
crash_report_warning: В прошлый раз приложение неожиданно закрылось, вы можете поделиться отчетом о сбое с разработчиками.
confirmation: Подтверждение
enter_url: Введите URL-адрес
max_short: МАКС
files_location: Расположение файлов
moving_files: Перемещение файлов
wrong_path_error: Указан неправильный путь
check_updates: Проверять обновления при запуске
update_available: Доступно обновление!
changelog: 'Журнал изменений:'
wallets:
await_conf_amount: Ожидает подтверждения
await_fin_amount: Ожидает завершения
locked_amount: Заблокировано
txs_empty: 'Для получения средств вручную или через транспорт используйте кнопки %{message} или %{transport} внизу экрана, для изменения настроек кошелька нажмите кнопку %{settings}.'
title: Кошельки
title: Goblin
create_desc: Создайте или импортируйте существующий кошелёк из сохранённой фразы восстановления.
add: Добавить кошелёк
name: 'Название:'
@@ -80,6 +93,7 @@ wallets:
tx_canceled: Отменено
tx_cancelling: Отмена
tx_finalizing: Завершение
tx_posting: Публикация
tx_confirmed: Подтверждено
txs: Транзакции
tx: Транзакция
@@ -123,6 +137,12 @@ wallets:
tx_receive_cancel_conf: 'Вы действительно хотите отменить получение %{amount} ツ?'
rec_phrase_not_found: Фраза восстановления не найдена.
restore_wallet_desc: Восстановить кошелёк, удалив все файлы, если обычное исправление не помогло. Необходимо переоткрыть кошелёк.
fee_base_desc: 'Комиссия (базовое значение%{value}):'
payment_proof: Подтверждение оплаты
payment_proof_desc: 'Введите полученное подтверждение оплаты для проверки транзакции:'
payment_proof_valid: 'Введённое подтверждение оплаты действительно:'
payment_proof_error: 'Введённое подтверждение оплаты недействительно:'
tx_delete_confirmation: Вы уверены, что хотите удалить транзакцию из истории?
transport:
desc: 'Используйте транспорт для синхронных получения или отправки сообщений:'
tor_network: Сеть Tor
@@ -135,7 +155,7 @@ transport:
incorrect_addr_err: 'Введённый адрес неверен:'
tor_send_error: Во время отправки через Tor произошла ошибка, убедитесь, что получатель находится онлайн, транзакция была отменена.
tor_autorun_desc: Запускать ли Tor сервис при открытии кошелька для синхронного получения транзакций.
tor_sending: 'Отправка %{amount} ツ через Tor'
tor_sending: Отправка через Tor
tor_settings: Настройки Tor
bridges: Мосты
bridges_desc: Настройте мосты для обхода цензуры сети Tor, если обычное соединение не работает.
@@ -280,13 +300,523 @@ network_settings:
ban_window_desc: Решение о запрете принимается узлом, основываясь на корректности данных полученных от пира.
max_inbound_count: 'Максимальное количество входящих подключений пиров:'
max_outbound_count: 'Максимальное количество исходящих подключений к пирам:'
reset_peers_desc: Сбросить данные пиров. Используйте с осторожностью, только при наличии проблем с поиском пиров.
reset_peers: Сбросить пиры
reset_data_desc: Сбросить данные узла. Используйте с осторожностью, только при наличии проблем с синхронизацией.
reset_data: Сброс данных
modal:
cancel: Отмена
save: Сохранить
confirmation: Подтверждение
add: Добавить
modal_exit:
description: Вы уверены, что хотите выйти из приложения?
exit: Выход
exit: Выход
app_settings:
proxy: Прокси
proxy_desc: Стоит ли использовать прокси для сетевых запросов из приложения.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: ъ
q: й
w: ц
e: у
r: к
t: е
y: н
u: г
i: ш
o: щ
p: з
p1: х
a: ф
s: ы
d: в
f: а
g: п
h: р
j: о
k: л
l: д
l1: ж
l2: э
z: я
x: ч
c: с
v: м
b: и
n: т
m: ь
m1: б
m2: ю
m3: ё
goblin:
home:
anonymous: "Аноним"
connected_nym: "Подключено через Tor"
nym_ready: "Tor готов · реле…"
connecting_nym: "Подключение к Tor…"
cant_reach_node: "Нет связи с узлом"
node_synced: "Узел синхронизирован"
syncing: "Синхронизация…"
balance_updating: "Баланс обновляется…"
listening: "Ожидание платежей"
block: "Блок %{height}"
waiting_for_chain: "Ожидание цепочки…"
nav_wallet: "Кошелёк"
nav_pay: "Оплатить"
nav_activity: "Действия"
nav_receive: "Получить"
nav_settings: "Настройки"
activity: "Действия"
empty_title: "Пока нет действий"
empty_sub: "Отправьте или получите grin, чтобы начать."
recent: "Недавние"
scan_to_pay: "Сканируйте для оплаты"
type_amount: "Введите сумму"
request: "Запросить"
pay: "Оплатить"
enter_amount: "Введите сумму для оплаты или запроса"
activity:
canceled: "отменено"
pending: "в ожидании"
earlier: "Ранее"
today: "Сегодня"
yesterday: "Вчера"
title: "Действия"
requests: "Запросы"
empty_title: "Пока нет действий"
empty_sub: "Здесь появятся ваши платежи."
pending_header: "В ожидании"
receipt:
title: "Квитанция"
not_found: "Транзакция не найдена"
for_note: "За %{note}"
details: "Детали транзакции"
canceled: "Отменено"
expired: "Истекло"
funds_returned: "Средства возвращены"
complete: "Завершено"
payment_received: "Платёж получен"
payment_sent: "Платёж успешно отправлен"
pending: "В ожидании"
confs: "%{c}/%{r} подтверждений"
waiting_to_confirm: "Ожидание подтверждения"
paying: "Оплата…"
you: "Вы"
to: "Кому"
from: "От"
nostr: "nostr"
fee_none: "Нет"
network_fee: "Сетевая комиссия"
privacy: "Приватность"
privacy_value: "Mimblewimble + 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: "Разрешить другим запрашивать у вас деньги"
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, 09, _ или -"
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"
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: "Gift wrap"
n59_blurb: "Оборачивает сообщения, чтобы реле не видели, кто с кем общается."
n98_title: "HTTP-авторизация"
n98_blurb: "Подписывает запрос регистрации имени на goblin.st."
onboarding:
intro:
private_money_head: "Приватные деньги"
private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке."
send_like_message_head: "Отправляйте как сообщение"
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и 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: "yourname"
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_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: "Квитанция"
+536 -6
View File
@@ -25,13 +25,26 @@ share: Paylasmak
theme: 'Tema:'
dark: Karanlik
light: Isik
file: Dosya
choose_file: Dosya seçin
choose_folder: Klasör seç
crash_report: Ariza Raporu
crash_report_warning: Uygulama beklenmedik bir sekilde kapandi son kez, kilitlenme raporunu gelistiricilerle paylasabilirsiniz.
confirmation: Onay
enter_url: URL'yi girin
max_short: MAKS
files_location: Dosya konumu
moving_files: Dosyalari Tasima
wrong_path_error: Yanlis yol belirtildi
check_updates: Başlangiçta güncellemeleri kontrol edin
update_available: Güncelleme mevcut!
changelog: 'Değişiklik Günlüğü:'
wallets:
await_conf_amount: Onay bekleniyor
await_fin_amount: Tamamlanma bekleniyor
locked_amount: Kilitli
txs_empty: 'Koinleri al/gonder icin ekranin altinda bulunan %{receive} / %{send} sekmeleri, cuzdan ayarlar icin %{settings} sekmesini kullanin.'
title: Cuzdanlar
title: Goblin
create_desc: Yeni cuzdan olustur veya var olan bakiyeli cuzdani kurtarma kelimelerinizle canlandirin.
add: Cuzdan ekle
name: 'Ad:'
@@ -80,6 +93,7 @@ wallets:
tx_canceled: Iptal edildi
tx_cancelling: Iptal ediliyor
tx_finalizing: Islem tamamlaniyor
tx_posting: Islem kaydetme
tx_confirmed: Onaylandi
txs: Islemler
tx: Islem
@@ -123,6 +137,12 @@ wallets:
tx_receive_cancel_conf: Gelen tx iptal
rec_phrase_not_found: Sifre kelime bulunmuyor
restore_wallet_desc: Cuzdani restore et
fee_base_desc: 'Ücret (taban değeri%{value}):'
payment_proof: Ödeme kaniti
payment_proof_desc: 'Islemi doğrulamak için alinan ödeme kanitini girin:'
payment_proof_valid: 'Girilen ödeme kaniti geçerlidir:'
payment_proof_error: 'Girilen ödeme kaniti geçerli değildir:'
tx_delete_confirmation: Islemi geçmişten silmek istediğinizden emin misiniz?
transport:
desc: 'Adresten senkronize GONDER veya AL:'
tor_network: Tor network
@@ -135,7 +155,7 @@ transport:
incorrect_addr_err: 'Girilen adres hatali:'
tor_send_error: Tor adresi uzerinden gonderimde aksaklik olustu, alici online olmasi gerek, islem iptal edildi.
tor_autorun_desc: Islemleri Tor adresi olarak AL,bunun için cuzdan acilisinda Tor hizmetinin baslatilip baslatilmayacagi.
tor_sending: 'Tor adrese %{amount} ツ gonderiliyor.'
tor_sending: Tor adrese gonderiliyor
tor_settings: Tor Ayarlar
bridges: Bridges
bridges_desc: Setup bridges to bypass Tor network censorship if usual connection is not working.
@@ -280,13 +300,523 @@ network_settings:
ban_window_desc: Banlama karari, peerden alinan verilerin dogruluguna bagli olarak Node tarafindan verilir.
max_inbound_count: 'Maksimum gelen Peer baglanti sayisi:'
max_outbound_count: 'Maksimum giden Peer baglanti sayisi:'
reset_peers_desc: Peers verilerini sifirlayin. Yalnizca Peers bulma konusunda sorun yasiyorsaniz dikkatli kullanin.
reset_peers: Peers Resetle
reset_data_desc: Node verisini sifirlama. Sadece senkronizasyonda sorun varsa dikkatli kullanin.
reset_data: Verileri sifirlama
modal:
cancel: Iptal
save: Kaydet
confirmation: Onay
add: Ekle
modal_exit:
description: Uygulamadan cikmak için exit, emin misiniz?
exit: Exit
exit: Exit
app_settings:
proxy: Proxy
proxy_desc: Uygulamadan gelen ağ istekleri için bir proxy kullanmaya değer mi.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '-'
q: q
w: w
e: e
r: r
t: t
y: y
u: u
i: i
o: o
p: p
p1: ü
a: a
s: s
d: d
f: f
g: g
h: h
j: j
k: k
l: l
l1: ö
l2: ':'
z: z
x: x
c: c
v: v
b: b
n: n
m: m
m1: ','
m2: .
m3: /
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…"
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"
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"
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 320 karakter: az, 09, _ 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_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"
+822
View File
@@ -0,0 +1,822 @@
lang_name: 英语
copy: 复制
paste: 粘贴
continue: 继续
complete: 完成
error: 错误
retry: 重试
close: 关闭
change: 更改
show: 显示
delete: 删除
clear: 清楚
create: 创建
id: 标识
kernel: 核心
settings: 设置
language: 语言
scan: 扫描
qr_code: 二维码
scan_qr: 扫描二维码
repeat: 重复
scan_result: 扫描结果
back: 返回
share: 分享
theme: '主题:'
dark: 深色
light: 淡色
file: 文件
choose_file: 选择文件
choose_folder: 选择文件夹
crash_report: 崩溃报告
crash_report_warning: 上次应用程序意外关闭,您可以报告开发人员崩溃事件.
confirmation: 确认
enter_url: 输入 URL
max_short: 最大數量
files_location: 檔案位置
moving_files: 檔案移動
wrong_path_error: 指定錯誤路徑
check_updates: 啟動時請查看更新
update_available: 最新消息已发布!
changelog: '更新日誌:'
wallets:
await_conf_amount: 等待确认中
await_fin_amount: 等待确定中
locked_amount: 锁定帐户
txs_empty: '手动接收资金或通过传输接收资金 %{message} or %{transport} 更改钱包设置, 请按屏幕底部的按钮 %{settings} 按钮.'
title: Goblin
create_desc: 创建或种子单词导入已有钱包.
add: 添加钱包
name: '用户名:'
pass: '密码:'
pass_empty: 输入钱包的密码
current_pass: '目前密码:'
new_pass: '新密码:'
min_tx_conf_count: '确认交易的最低数量:'
recover: 恢复
recovery_phrase: 助记词
words_count: '字数:'
enter_word: '输入单词 #%{number}:'
not_valid_word: 输入的单词无效
not_valid_phrase: 输入的助记词无效
create_phrase_desc: 已安全地写下并保存助记词.
restore_phrase_desc: 从已保存的助记词中输入.
setup_conn_desc: 选择钱包连接到网络的方式.
conn_method: 连接方式
ext_conn: '外部连接:'
add_node: 添加节点
node_url: '节点网址:'
node_secret: 'API 密钥 (可选):'
invalid_url: 输入的网址无效
open: 打开钱包
wrong_pass: 输入的密码错误
locked: 已锁定
unlocked: 解锁
enable_node: '通过选择屏幕底部的按钮 %{settings} 启用集成节点以使用钱包或更改连接设置.'
node_loading: '集成节点同步后钱包会加载,你可选择屏幕底部的按钮 %{settings} 更改连接.'
loading: 正在加载
closing: 正在关闭
checking: 检查中
default_wallet: 默认钱包
new_account_desc: '输入新帐户的名称:'
wallet_loading: 加载钱包
wallet_closing: 关闭钱包
wallet_checking: 检查钱包
tx_loading: 加载事务
default_account: 默认账户
accounts: 账户
tx_sent: 已发送
tx_received: 已接收
tx_sending: 发送中
tx_receiving: 接收中
tx_confirming: 等待确认
tx_canceled: 已取消
tx_cancelling: 取消
tx_finalizing: 完成
tx_posting: 过账交易
tx_confirmed: 已确认
txs: 所有交易
tx: 交易
messages: 消息
transport: 传输
input_slatepack_desc: '输入收到的 Slatepack 消息创建响应或完成的请求:'
parse_slatepack_err: '读取消息时出错,请检查输入:'
pay_balance_error: '账户余额不足以支付 %{amount} ツ 和网络费用.'
parse_i1_slatepack_desc: '要支付 %{amount} ツ 请将此消息发送给接收者:'
parse_i2_slatepack_desc: '完成交易以接收 %{amount} ツ:'
parse_i3_slatepack_desc: '发布交易以完成 %{amount} ツ的接收 ツ:'
parse_s1_slatepack_desc: '要接收 %{amount} ツ 请将此消息发送给发件人:'
parse_s2_slatepack_desc: '完成交易以发送 %{amount} ツ:'
parse_s3_slatepack_desc: '发布交易以完成 %{amount} ツ的发送:'
resp_slatepack_err: '创建响应时出错,请检查输入数据或重试:'
resp_exists_err: 此交易已存在.
resp_canceled_err: 此交易已被取消.
create_request_desc: '创建发送或接收资金的请求:'
send_request_desc: '您已创建发送请求 %{amount} ツ. 将此消息发送给接收者:'
send_slatepack_err: 创建发送资金请求时出错,请检查输入数据或重试.
invoice_desc: '您已创建接收请求 %{amount} ツ. 将此消息发送给发送者:'
invoice_slatepack_err: 发票开具时出错,请检查输入数据或重试.
finalize_slatepack_err: '完结时出错,请检查输入数据或重试:'
finalize: 完成
use_dandelion: 使用蒲公英
enter_amount_send: '你有 %{amount} ツ. 输入要发送的金额:'
enter_amount_receive: '输入要接收的金额:'
recovery: 恢复
repair_wallet: 修复钱包
repair_desc: 检查钱包,必要时修复和恢复丢失的输出. 此操作需要时间.
repair_unavailable: 您需要与节点建立有效连接并完成钱包同步.
delete: 删除钱包
delete_conf: 您确定要删除钱包吗?
delete_desc: 确保您已保存恢复助记语,以便日后使用资金。.
wallet_loading_err: '同步钱包时出错,你可以通过选择屏幕底部的按钮 %{settings} 来重试或更改连接设置.'
wallet: 钱包
send: 发送
receive: 接收
settings: 钱包设置
tx_send_cancel_conf: '您确定要取消 %{amount} ツ的发送吗?'
tx_receive_cancel_conf: '您确定要取消 %{amount} ツ的接收吗?'
rec_phrase_not_found: 找不到恢复助记词.
restore_wallet_desc: 如果常规修复没有帮助,通过删除所有文件来恢复钱包.您将需要重新打开您的钱包.
fee_base_desc: '费用 (基值%{value}):'
payment_proof: 付款證明
payment_proof_desc: '輸入已收款證明以驗證交易:'
payment_proof_valid: '輸入的付款證明有效:'
payment_proof_error: '輸入的付款證明無效:'
tx_delete_confirmation: 你確定要從歷史紀錄中刪除這筆交易嗎?
transport:
desc: '使用传输同步接收或发送消息:'
tor_network: Tor 网络
connected: 已连接
connecting: 正在连接
disconnecting: 断开连接
conn_error: 连接错误
disconnected: 已断开连接
receiver_address: '接收者的地址:'
incorrect_addr_err: '输入的地址不正确:'
tor_send_error: 通过 Tor 发送时出错,请确保接收方在线, 交易已取消.
tor_autorun_desc: 是否在开钱包时启动 Tor 服务以同步接收交易.
tor_sending: 通过 Tor 发送
tor_settings: Tor 设置
bridges: 桥梁
bridges_desc: 如果常规连接不正常,设置网桥,可以绕过 Tor 网络审查.
bin_file: '二进制文件:'
conn_line: '连接线:'
bridges_disabled: 网桥已禁用
bridge_name: '网桥%{b}'
network:
self: 网络
type: '网络类型:'
mainnet: 主网
testnet: 测试网
connections: 连接
node: 集成节点
metrics: 指标
mining: 挖矿
settings: 节点设置
enable_node: 启用节点
autorun: 自动运行
disabled_server: '按屏幕左上角的按钮 %{dots}启用集成节点或添加其他连接方法.'
no_ips: T您的系统上没有可用的 IP 地址,服务器无法启动,请检查您的网络连接.
available: 可用
not_available: 不可用
availability_check: 检查是否可用
android_warning: Android 用户注意 .要成功同步集成节点,您必须在手机的系统设置中允许访问通知并取消 Grim 应用程序的电池使用限制.这是在后台正确运行应用程序的必要操作.
sync_status:
node_restarting: 节点正在重新启动
node_down: 节点已关闭
initial: 节点正在启动
no_sync: 节点正在运行
awaiting_peers: 等待网络对点
header_sync: 正下载标题
header_sync_percent: '正在下载标题: %{percent}%'
tx_hashset_pibd: 下载状态 (PIBD)
tx_hashset_pibd_percent: '下载状态 (PIBD): %{percent}%'
tx_hashset_download: 正在下载状态
tx_hashset_download_percent: '下载状态: %{percent}%'
tx_hashset_setup_history: '正在准备状态(历史记录): %{percent}%'
tx_hashset_setup_position: '正在准备状态(位置): %{percent}%'
tx_hashset_setup: 正在准备状态
tx_hashset_range_proofs_validation: '验证状态(范围证明): %{percent}%'
tx_hashset_kernels_validation: '正在验证状态(核心): %{percent}%'
tx_hashset_save: 最终确定链状态
body_sync: 下载区块
body_sync_percent: '下载区块中: %{percent}%'
shutdown: 节点正在关闭
network_node:
header: 标题
block: 区块
hash: 哈希值
height: 高度
difficulty: 难度
time: 时间
main_pool: 主池
stem_pool: stem池
data: 数据
size: 大小 (GB)
peers: 网络对点
error_clean: 点数据已损坏,需要重新同步.
resync: 重新同步
error_p2p_api: '%{p2p_api} 服务器初始化时出错,请选择屏幕底部的按钮 %{p2p_api} 来检查 %{settings}设置.'
error_config: '配置初始化时出错,请选择屏幕底部的按钮 %{settings} 检查设置.'
error_unknown: '初始化时出错,请选择屏幕底部的按钮 %{settings} 来检查集成节点设置,或者重新同步.'
network_metrics:
loading: 指标在同步后将可用
emission: 发射
inflation: 通货膨胀
supply: 供应
block_time: Block time
reward: 奖励
difficulty_window: '难度窗口 %{size}'
network_mining:
loading: 同步后即可挖矿
info: '挖矿服务器已启用,您可以通过选择屏幕底部的按钮 %{settings} 来更改其设置。连接设备后,数据会更新.'
restart_server_required: 需要重启服务器才能应用更改.
rewards_wallet: 奖励钱包
server: 阶层服务器
address: 地址
miners: 矿工
devices: 设备
blocks_found: 找到的区块
hashrate: '哈希率 (C%{bits})'
connected: 已连接
disconnected: 已断开连接
network_settings:
change_value: 更改值
stratum_ip: '层 IP 地址:'
stratum_port: '层端口:'
port_unavailable: 指定的端口不可用
restart_node_required: 需要重启节点才能应用更改.
choose_wallet: 选择钱包
stratum_wallet_warning: 必须打开钱包才能获得奖励.
enable: 启用
disable: 禁用
restart: 重新启动
server: 服务器
api_ip: 'API IP 地址:'
api_port: 'API 端口:'
api_secret: '其它API 和 V2 所有者 API 令牌:'
foreign_api_secret: '外部 API 令牌:'
disabled: 已禁用
enabled: 已启用
ftl: '未来时间限制 (FTL):'
ftl_description: 限制未来多长时间, 相对于节点的本地时间,以秒为单位, 新区块的时间戳可以被接受.
not_valid_value: 输入的值无效
full_validation: 完全验证
full_validation_description: 在处理每个区块时是否运行全链验证(同步期间除外).
archive_mode: 存档模式
archive_mode_desc: 以全部存档模式运行全节点(同步需要更多的磁盘空间和时间).
attempt_time: '尝试挖矿时间 (秒):'
attempt_time_desc: 在停止并从池中重新收集交易之前尝试对特定标题进行挖矿的时间
min_share_diff: '可接受的最低份额难度:'
reset_settings_desc: 将节点设置重置为默认值
reset_settings: 重置设置
reset: 重置
tx_pool: 交易池
pool_fee: '接受到矿池的基本费用:'
reorg_period: '重组缓存保留期(以分钟为单位):'
max_tx_pool: '池中的最大交易数:'
max_tx_stempool: 'stem池中的最大交易数:'
max_tx_weight: '可以选择构建区块交易的最大总权重:'
epoch_duration: '纪元持续时间(以秒为单位):'
embargo_timer: '禁止计时器(以秒为单位):'
aggregation_period: '聚合周期(以秒为单位):'
stem_probability: 'stem助记词概率:'
stem_txs: stem交易
p2p_server: P2P 服务器
p2p_port: 'P2P 端口:'
add_seed: 添加 DNS 种子
seed_address: 'DNS 种子地址:'
add_peer: 添加网络对点
peer_address: '网络对点地址:'
peer_address_error: '以正确的格式输入 IP 地址或 DNS 名称(确保指定的主机可用),例如:192.168.0.11234 或 example.com:5678'
default: 默认
allow_list: 允许列表
allow_list_desc: 仅连接到此列表中的网络对点.
deny_list: 拒绝列表
deny_list_desc: 切勿连接到此列表中的网络对点.
favourites: 收藏夹
favourites_desc: 要连接的首选网络对点列表.
ban_window: '被封禁的网络对点应该保持被封禁多长时间(以秒为单位):'
ban_window_desc: 禁止的决定是由节点 根据从网络对点收到的数据的正确性做出的.
max_inbound_count: '入站网络对点连接的最大数量:'
max_outbound_count: '最大出站网络对点连接数:'
reset_data_desc: 重置节点数据。只有在出现同步问题时才需谨慎使用.
reset_data: 重置数据
modal:
cancel: 取消
save: 保存
add: 添加
modal_exit:
description: 您确定要退出应用程序吗?
exit: 退出手
app_settings:
proxy: 代理
proxy_desc: 是否值得对来自应用程序的网络请求使用代理.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '-'
q:
w:
e:
r:
t: 廿
y:
u:
i:
o:
p:
p1: '"'
a:
s:
d:
f:
g:
h:
j:
k:
l:
l1: \
l2: ':'
z:
x:
c:
v:
b:
n:
m:
m1: ','
m2: .
m3: /
goblin:
home:
anonymous: "匿名"
connected_nym: "已通过 Tor 连接"
nym_ready: "Tor 就绪 · 连接中继…"
connecting_nym: "正在连接 Tor…"
cant_reach_node: "无法连接节点"
node_synced: "节点已同步"
syncing: "同步中…"
balance_updating: "余额更新中…"
listening: "正在监听付款"
block: "区块 %{height}"
waiting_for_chain: "等待链数据…"
nav_wallet: "钱包"
nav_pay: "支付"
nav_activity: "动态"
nav_receive: "收款"
nav_settings: "设置"
activity: "动态"
empty_title: "暂无动态"
empty_sub: "收发 grin 即可开始。"
recent: "最近"
scan_to_pay: "扫码支付"
type_amount: "输入金额"
request: "请求"
pay: "支付"
enter_amount: "输入要支付或请求的金额"
activity:
canceled: "已取消"
pending: "待处理"
earlier: "更早"
today: "今天"
yesterday: "昨天"
title: "动态"
requests: "请求"
empty_title: "暂无动态"
empty_sub: "你的付款将显示在这里。"
pending_header: "待处理"
receipt:
title: "收据"
not_found: "未找到交易"
for_note: "用于 %{note}"
details: "交易详情"
canceled: "已取消"
expired: "已过期"
funds_returned: "资金已退回"
complete: "已完成"
payment_received: "已收到付款"
payment_sent: "付款发送成功"
pending: "待处理"
confs: "%{c}/%{r} 次确认"
waiting_to_confirm: "等待确认"
paying: "支付中…"
you: "你"
to: "收款方"
from: "付款方"
nostr: "nostr"
fee_none: "无"
network_fee: "网络费用"
privacy: "隐私"
privacy_value: "Mimblewimble + 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: "允许他人向你请求付款"
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 个字符:az、09、_ 或 -"
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_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: "收据"
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Goblin</string>
<key>CFBundleExecutable</key>
<string>goblin</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>mw.gri.macos</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Goblin</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.3.6</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSCameraUsageDescription</key>
<string>Goblin needs an access to your camera to scan QR code.</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Apple SimpleText document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.apple.traditional-mac-plain-text</string>
</array>
<key>NSDocumentClass</key>
<string>Document</string>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Unknown document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>public.data</string>
</array>
<key>NSDocumentClass</key>
<string>Document</string>
</dict>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>NSHumanReadableCopyright</key>
<string>2024</string>
</dict>
</plist>
@@ -0,0 +1,2 @@
!.gitignore
grim
Binary file not shown.
-49
View File
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Grim</string>
<key>CFBundleExecutable</key>
<string>grim</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>mw.gri.macos</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Grim</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>NSHumanReadableCopyright</key>
<string>2024</string>
</dict>
</plist>
Binary file not shown.
@@ -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>
+18 -25
View File
@@ -1,22 +1,21 @@
#!/bin/bash
set -e
case $2 in
case $1 in
x86_64|arm|universal)
;;
*)
echo "Usage: release_macos.sh [version] [platform]\n - platform: 'x86_64', 'arm', 'universal'" >&2
echo "Usage: release_macos.sh [platform] [version]\n - platform: 'x86_64', 'arm', 'universal'" >&2
exit 1
esac
if [[ ! -v SDKROOT ]]; then
if [[ "$OSTYPE" != "darwin"* ]]; then
if [ -z ${SDKROOT+x} ]; then
echo "MacOS SDKROOT is not set"
exit 1
elif [[ -z "SDKROOT" ]]; then
echo "MacOS SDKROOT is set to the empty string"
exit 1
else
else
echo "Use MacOS SDK: ${SDKROOT}"
fi
fi
# Setup build directory
@@ -25,31 +24,25 @@ cd ${BASEDIR}
cd ..
# Setup platform
[[ $1 == "x86_64" ]] && arch+=(x86_64-apple-darwin)
[[ $1 == "arm" ]] && arch+=(aarch64-apple-darwin)
rustup target add x86_64-apple-darwin
rustup target add aarch64-apple-darwin
rm -rf target/x86_64-apple-darwin
rm -rf target/aarch64-apple-darwin
[[ $2 == "x86_64" ]] && arch+=(x86_64-apple-darwin)
[[ $2 == "arm" ]] && arch+=(aarch64-apple-darwin)
[[ $2 == "universal" ]] && arch+=(universal2-apple-darwin)
# Start release build with zig linker for cross-compilation
# zig 0.12+ required
[[ $1 == "universal" ]]; arch+=(universal2-apple-darwin)
cargo install cargo-zigbuild
cargo zigbuild --release --target ${arch}
rm -rf .intentionally-empty-file.o
mkdir macos/Grim.app/Contents/MacOS
yes | cp -rf target/${arch}/release/grim macos/Grim.app/Contents/MacOS
### Sign .app resources on change:
rm -f .intentionally-empty-file.o
yes | cp -rf target/${arch}/release/goblin macos/Goblin.app/Contents/MacOS
# Sign .app resources on change:
#rcodesign generate-self-signed-certificate
#rcodesign sign --pem-file cert.pem macos/Grim.app
#rcodesign sign --pem-file cert.pem macos/Goblin.app
# Create release package
FILE_NAME=grim-v$1-macos-$2.zip
rm -rf target/${arch}/release/${FILE_NAME}
FILE_NAME=goblin-v$2-macos-$1.zip
cd macos
zip -r ${FILE_NAME} Grim.app
zip -r ${FILE_NAME} Goblin.app
mv ${FILE_NAME} ../target/${arch}/release
Submodule
+1
Submodule node added at bce5a7144b
+2
View File
@@ -0,0 +1,2 @@
hard_tabs = true
edition = "2024"
+121 -64
View File
@@ -1,81 +1,138 @@
#!/bin/bash
usage="Usage: build_run_android.sh [type] [platform]\n - type: 'debug', 'release'\n - platform: 'v7', 'v8'"
usage="Usage: android.sh [type] [platform|version] [flavor]\n - type: 'build' to run locally, 'lib' - .so for all platforms, 'release' - .apk for all platforms\n - platform, for 'build' type: 'v7', 'v8', 'x86'\n - version for 'lib' and 'release', example: '0.2.2'\n - optional flavor, for non-'lib' type: 'ci' for local maven, default - 'local' for external"
case $1 in
debug|release)
build|lib|release)
;;
*)
printf "$usage"
exit 1
esac
case $2 in
v7|v8)
;;
*)
printf "$usage"
exit 1
esac
# Setup build directory
BASEDIR=$(cd $(dirname $0) && pwd)
cd ${BASEDIR}
cd ..
# Setup release argument
type=$1
[[ ${type} == "release" ]] && release_param="--profile release-apk"
# Setup platform argument
[[ $2 == "v7" ]] && arch+=(armeabi-v7a)
[[ $2 == "v8" ]] && arch+=(arm64-v8a)
# Setup platform path
[[ $2 == "v7" ]] && platform+=(armv7-linux-androideabi)
[[ $2 == "v8" ]] && platform+=(aarch64-linux-android)
# Install platform
[[ $2 == "v7" ]] && rustup target install armv7-linux-androideabi
[[ $2 == "v8" ]] && rustup target install aarch64-linux-android
# Build native code
cargo install cargo-ndk
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
# temp fix for https://stackoverflow.com/questions/57193895/error-use-of-undeclared-identifier-pthread-mutex-robust-cargo-build-liblmdb-s
success=0
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0"
cargo ndk -t ${arch} build ${release_param}
unset CPPFLAGS && unset CFLAGS
cargo ndk -t ${arch} -o android/app/src/main/jniLibs build ${release_param}
if [ $? -eq 0 ]
then
success=1
if [[ $1 == "build" ]]; then
case $2 in
v7|v8|x86)
;;
*)
printf "$usage"
exit 1
esac
fi
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
# Setup build directory
BASEDIR=$(cd "$(dirname "$0")" && pwd)
cd "${BASEDIR}" || exit 1
cd ..
# Build Android application and launch at all connected devices
if [ $success -eq 1 ]
then
cd android
# Prefer the GRIM-canonical toolchain: the custom NDK r29 (rebuilt LLVM, 16 KB
# page-aligned) + Android SDK from code.gri.mw/DEV. scripts/toolchain.sh fetches
# them and writes this env; falls back to whatever NDK/SDK is on the system.
[ -f .toolchains/env.sh ] && source .toolchains/env.sh
# Setup gradle argument
[[ $1 == "release" ]] && gradle_param+=(assembleRelease)
[[ $1 == "debug" ]] && gradle_param+=(build)
# Install platforms and tools
rustup target add armv7-linux-androideabi
rustup target add aarch64-linux-android
rustup target add x86_64-linux-android
cargo install cargo-ndk
success=1
### Build native code
function build_lib() {
[[ $1 == "v7" ]] && arch=armeabi-v7a
[[ $1 == "v8" ]] && arch=arm64-v8a
[[ $1 == "x86" ]] && arch=x86_64
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
sed -i -e 's/"rlib"]/"cdylib","rlib"]/g' Cargo.toml
cargo ndk -t "${arch}" -o android/app/src/main/jniLibs build --profile release-apk
if [ $? -ne 0 ]; then
success=0
fi
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
rm -f Cargo.toml-e
# The 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
[[ -z "$flavor" ]] && flavor="local"
cd android || exit 1
./gradlew clean
./gradlew ${gradle_param}
# Build signed apk if keystore exists
if [ ! -f keystore.properties ]; then
./gradlew assemble${flavor}Debug
if [ $? -ne 0 ]; then
success=0
fi
apk_path=app/build/outputs/apk/${flavor}/debug/app-${flavor}-debug.apk
else
./gradlew assemble${flavor}SignedRelease
if [ $? -ne 0 ]; then
success=0
fi
apk_path=app/build/outputs/apk/${flavor}/signedRelease/app-${flavor}-signedRelease.apk
fi
# Setup apk path
[[ $1 == "release" ]] && apk_path+=(app/build/outputs/apk/release/app-release.apk)
[[ $1 == "debug" ]] && apk_path+=(app/build/outputs/apk/debug/app-debug.apk)
if [[ $1 == "" ]] && [ $success -eq 1 ]; then
# Launch application at all connected devices. 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 "${app_id}/mw.gri.android.MainActivity";
done
elif [ $success -eq 1 ]; then
# Get version
version=$2
if [[ -z "$version" ]]; then
version=v$(grep -m 1 -Po 'version = "\K[^"]*' ../Cargo.toml)
fi
# Setup release file name
name=grim-${version}-android-$1.apk
[[ $1 == "arm" ]] && name=grim-${version}-android.apk
rm -f "${name}"
mv ${apk_path} "${name}"
# Calculate checksum
checksum=grim-${version}-android-$1-sha256sum.txt
[[ $1 == "arm" ]] && checksum=grim-${version}-android-sha256sum.txt
rm -f "${checksum}"
sha256sum "${name}" > "${checksum}"
fi
for SERIAL in $(adb devices | grep -v List | cut -f 1);
do
adb -s $SERIAL install ${apk_path}
sleep 1s
adb -s $SERIAL shell am start -n mw.gri.android/.MainActivity;
done
fi
cd ..
}
rm -rf android/app/src/main/jniLibs/*
if [[ $1 == "lib" ]]; then
build_lib "v7"
[ $success -eq 1 ] && build_lib "v8"
[ $success -eq 1 ] && build_lib "x86"
[ $success -eq 1 ] && exit 0
elif [[ $1 == "build" ]]; then
build_lib "$2"
[ $success -eq 1 ] && build_apk "" "" "$3"
[ $success -eq 1 ] && exit 0
else
rm -rf target/release-apk
rm -rf target/aarch64-linux-android
rm -rf target/x86_64-linux-android
rm -rf target/armv7-linux-androideabi
build_lib "v7"
[ $success -eq 1 ] && build_lib "v8"
[ $success -eq 1 ] && build_apk "arm" "$2" "$3"
rm -rf android/app/src/main/jniLibs/*
[ $success -eq 1 ] && build_lib "x86"
[ $success -eq 1 ] && build_apk "x86_64" "$2" "$3"
[ $success -eq 1 ] && exit 0
fi
exit 1
+10 -8
View File
@@ -1,25 +1,27 @@
#!/bin/bash
case $1 in
debug|release)
debug|build)
;;
*)
echo "Usage: build_run.sh [type] where is type is 'debug' or 'release'" >&2
echo "Usage: build_run.sh [type] where is type is 'debug' or 'build'" >&2
exit 1
esac
# Setup build directory
BASEDIR=$(cd $(dirname $0) && pwd)
cd ${BASEDIR}
BASEDIR=$(cd "$(dirname $0)" && pwd)
cd "${BASEDIR}" || return
cd ..
# Build application
type=$1
[[ ${type} == "release" ]] && release_param+=(--release)
cargo build ${release_param[@]}
[[ ${type} == "build" ]] && release_param+=(--release)
cargo --config profile.release.incremental=true build "${release_param[@]}"
# Start application
if [ $? -eq 0 ]
then
./target/${type}/grim
fi
path=${type}
[[ ${type} == "build" ]] && path="release"
./target/"${path}"/grim
fi
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Regenerate EVERY platform's app icon from two canonical sources:
# img/goblin-icon.png the gradient app icon (yellow gradient + black mascot)
# img/goblin-mark-black.svg the black mascot mark, vector, transparent bg
# (mirror of site/assets/goblin-mark-black.svg)
#
# Square icons (desktop window, Linux AppImage, Android launcher, Windows .ico,
# macOS .icns) come from the gradient PNG. The Android *adaptive* foreground is
# the black mascot on transparency, composited by the OS over the yellow
# background color (res/values/ic_launcher_background.xml = #FFD60A) — which
# reproduces the gradient icon's look.
#
# Requires ImageMagick (magick) and python3 (for the .icns container).
set -euo pipefail
cd "$(dirname "$0")/.."
ICON=img/goblin-icon.png
MARK=img/goblin-mark-black.svg
RES=android/app/src/main/res
# --- Desktop window icon (egui, src/main.rs) + Linux AppImage AppDir icon ---
magick "$ICON" -resize 256x256 PNG32:img/icon.png
cp img/icon.png linux/Goblin.AppDir/goblin.png
# --- Android launcher icons (gradient square) + adaptive foreground (mascot) ---
declare -A SIZES=( [mdpi]=48 [hdpi]=72 [xhdpi]=96 [xxhdpi]=144 [xxxhdpi]=192 )
declare -A FG_SIZES=( [mdpi]=108 [hdpi]=162 [xhdpi]=216 [xxhdpi]=324 [xxxhdpi]=432 )
for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
s=${SIZES[$d]}; fg=${FG_SIZES[$d]}
# Mascot fills ~60% of the adaptive canvas — inside the ~61% safe zone, so no
# launcher mask (circle/squircle) ever clips it.
art=$(( fg * 60 / 100 ))
magick "$ICON" -resize "${s}x${s}" PNG32:"$RES/mipmap-$d/ic_launcher.png"
magick "$ICON" -resize "${s}x${s}" PNG32:"$RES/mipmap-$d/ic_launcher_round.png"
magick -background none "$MARK" -resize "${art}x${art}" \
-gravity center -extent "${fg}x${fg}" PNG32:"$RES/mipmap-$d/ic_launcher_foreground.png"
done
# --- 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"
+60
View File
@@ -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()
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
#
# Fetch the canonical GRIM build toolchains (code.gri.mw/DEV) into .toolchains/.
#
# These mirror exactly what upstream GRIM's CI uses, so Goblin cross-builds every
# platform from one Linux box the same way GRIM does — instead of relying on
# whatever NDK/zig/appimagetool happens to be installed on the machine.
#
# Idempotent: each tool is skipped if already present. Linux x86_64 host only
# (the box we build releases on). Writes .toolchains/env.sh with the exports the
# build scripts source; run nothing else by hand.
#
# Usage:
# scripts/toolchain.sh # core: ndk zig appimage (what desktop+android need)
# scripts/toolchain.sh sdk gradle # add the Android SDK + Gradle
# scripts/toolchain.sh osxcross # build the macOS cross-toolchain (heavy)
# scripts/toolchain.sh all # everything
#
set -euo pipefail
BASEDIR=$(cd "$(dirname "$0")/.." && pwd)
TC="${BASEDIR}/.toolchains"
DEV="https://code.gri.mw/DEV"
mkdir -p "${TC}"
dl() { echo "$(basename "$2")"; curl -fSL --retry 3 -o "$2" "$1"; }
# Pinned versions — bump here to track GRIM's DEV releases.
NDK_TAG="r29"; NDK_ARCHIVE="android-ndk-${NDK_TAG}-x86_64-linux-musl.tar.xz"; NDK_DIR="${TC}/android-ndk-${NDK_TAG}"
ZIG_VER="0.12.1"; ZIG_DIR="${TC}/zig"
AT_VER="1.9.1"; RT_TAG="20251108"
SDK_TAG="r36"; SDK_DIR="${TC}/android-sdk"
GRADLE_VER="8.13"; GRADLE_DIR="${TC}/gradle-${GRADLE_VER}"
SDK_VER="12.3"; OSX_DIR="${TC}/osxcross"
fetch_ndk() {
[ -e "${NDK_DIR}/source.properties" ] && { echo "ndk r29: present"; return; }
echo "ndk: fetching custom NDK ${NDK_TAG} (rebuilt LLVM, 16 KB-aligned)…"
dl "${DEV}/android-ndk-custom/releases/download/${NDK_TAG}/${NDK_ARCHIVE}" "${TC}/ndk.tar.xz"
tar -xJf "${TC}/ndk.tar.xz" -C "${TC}"; rm -f "${TC}/ndk.tar.xz"
}
fetch_zig() {
[ -x "${ZIG_DIR}/zig" ] && { echo "zig ${ZIG_VER}: present"; return; }
echo "zig: fetching ${ZIG_VER} (linker for cargo-zigbuild)…"
dl "${DEV}/zig/releases/download/${ZIG_VER}/zig-linux-x86_64-${ZIG_VER}.tar.xz" "${TC}/zig.tar.xz"
tar -xJf "${TC}/zig.tar.xz" -C "${TC}"; rm -f "${TC}/zig.tar.xz"
rm -rf "${ZIG_DIR}"; mv "${TC}/zig-linux-x86_64-${ZIG_VER}" "${ZIG_DIR}"
}
fetch_appimage() {
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && [ -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"
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
# the DEV mirror so gradle has an SDK without a system install.
fetch_sdk() {
[ -d "${SDK_DIR}/platform-tools" ] && { echo "android-sdk ${SDK_TAG}: present"; return; }
echo "android-sdk: fetching build-tools + platform-36 + platform-tools (${SDK_TAG})…"
local base="${DEV}/android-platform-tools/releases/download/${SDK_TAG}"
mkdir -p "${SDK_DIR}/build-tools" "${SDK_DIR}/platforms"
dl "${base}/build-tools_r36.1_linux.zip" "${TC}/bt.zip"
dl "${base}/platform-36_r02.zip" "${TC}/pf.zip"
dl "${base}/platform-tools_r36.0.0-linux.zip" "${TC}/pt.zip"
# build-tools zip unzips to android-NN/ → rename to the version dir gradle wants.
local tmp; tmp=$(mktemp -d)
unzip -q "${TC}/bt.zip" -d "${tmp}"; mv "${tmp}"/*/ "${SDK_DIR}/build-tools/36.1.0"
unzip -q "${TC}/pf.zip" -d "${tmp}"; mv "${tmp}"/*/ "${SDK_DIR}/platforms/android-36"
unzip -q "${TC}/pt.zip" -d "${SDK_DIR}"
rm -rf "${tmp}" "${TC}/bt.zip" "${TC}/pf.zip" "${TC}/pt.zip"
}
fetch_gradle() {
[ -x "${GRADLE_DIR}/bin/gradle" ] && { echo "gradle ${GRADLE_VER}: present"; return; }
echo "gradle: fetching ${GRADLE_VER}"
dl "${DEV}/gradle/releases/download/v${GRADLE_VER}/gradle-${GRADLE_VER}-bin.zip" "${TC}/gradle.zip"
unzip -q "${TC}/gradle.zip" -d "${TC}"; rm -f "${TC}/gradle.zip"
}
# osxcross: build the macOS cross-toolchain from source + the DEV macOS SDK.
# Heavy (compiles cctools/ld64); enables building macOS binaries off-Mac. CI also
# builds macOS natively, so this is the local/offline path — experimental.
fetch_osxcross() {
[ -x "${OSX_DIR}/target/bin/o64-clang" ] && { echo "osxcross: present"; return; }
command -v clang >/dev/null || { echo "osxcross: needs system clang/cmake — skipping"; return; }
echo "osxcross: cloning + building with macOS SDK ${SDK_VER} (slow)…"
[ -d "${OSX_DIR}/.git" ] || git clone --depth 1 "${DEV}/osxcross" "${OSX_DIR}"
dl "${DEV}/macosx-sdks/releases/download/${SDK_VER}/MacOSX${SDK_VER}.sdk.tar.xz" "${OSX_DIR}/tarballs/MacOSX${SDK_VER}.sdk.tar.xz"
( cd "${OSX_DIR}" && UNATTENDED=1 ./build.sh )
}
write_env() {
{
echo "# Auto-generated by scripts/toolchain.sh — source me for GRIM-canonical builds."
[ -e "${NDK_DIR}/source.properties" ] && { echo "export ANDROID_NDK_HOME=\"${NDK_DIR}\""; echo "export ANDROID_NDK_ROOT=\"${NDK_DIR}\""; }
[ -d "${SDK_DIR}/platform-tools" ] && echo "export ANDROID_HOME=\"${SDK_DIR}\""
local p="${TC}"
[ -x "${ZIG_DIR}/zig" ] && p="${ZIG_DIR}:${p}"
[ -x "${GRADLE_DIR}/bin/gradle" ] && p="${GRADLE_DIR}/bin:${p}"
[ -x "${OSX_DIR}/target/bin/o64-clang" ] && p="${OSX_DIR}/target/bin:${p}"
echo "export PATH=\"${p}:\$PATH\""
echo "export GOBLIN_APPIMAGETOOL=\"${TC}/appimagetool\""
echo "export GOBLIN_APPIMAGE_RUNTIME=\"${TC}/runtime-x86_64\""
} > "${TC}/env.sh"
}
tools=("$@"); [ ${#tools[@]} -eq 0 ] && tools=(ndk zig appimage)
[ "${tools[0]:-}" = "all" ] && tools=(ndk zig appimage sdk gradle osxcross)
for t in "${tools[@]}"; do
case "$t" in
ndk) fetch_ndk ;;
zig) fetch_zig ;;
appimage) fetch_appimage ;;
sdk) fetch_sdk ;;
gradle) fetch_gradle ;;
osxcross) fetch_osxcross ;;
*) echo "unknown tool: $t (ndk|zig|appimage|sdk|gradle|osxcross|all)" >&2; exit 1 ;;
esac
done
write_env
echo "toolchain ready → ${TC}/env.sh"
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# Usage to bump version
# ./version.sh patch
# ./version.sh minor
# ./version.sh major
# Setup base directory
BASEDIR=$(cd $(dirname $0) && pwd)
cd ${BASEDIR}
cd ..
# Exit script if command fails or uninitialized variables used
set -euo pipefail
# ==================================
# Verify repo is clean
# ==================================
# List uncommitted changes and
# check if the output is not empty
if [ -n "$(git status --porcelain)" ]; then
# Print error message
printf "\nError: repo has uncommitted changes\n\n"
# Exit with error code
exit 1
fi
# ==================================
# Get latest version from git tags
# ==================================
# List git tags sorted lexicographically
# so version numbers sorted correctly
GIT_TAGS=$(git tag --sort=version:refname)
# Get last line of output which returns the
# last tag (most recent version)
GIT_TAG_LATEST=$(echo "$GIT_TAGS" | tail -n 1)
# If no tag found, default to v0.1.0
if [ -z "$GIT_TAG_LATEST" ]; then
GIT_TAG_LATEST="v0.1.0"
fi
# Strip prefix 'v' from the tag to easily increment
GIT_TAG_LATEST=$(echo "$GIT_TAG_LATEST" | sed 's/^v//')
# ==================================
# Increment version number
# ==================================
# Get version type from first argument passed to script
VERSION_TYPE="${1-}"
VERSION_NEXT=""
if [ "$VERSION_TYPE" = "patch" ]; then
# Increment patch version
VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$NF++; print $1"."$2"."$NF}')"
elif [ "$VERSION_TYPE" = "minor" ]; then
# Increment minor version
VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$2++; $3=0; print $1"."$2"."$3}')"
elif [ "$VERSION_TYPE" = "major" ]; then
# Increment major version
VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$1++; $2=0; $3=0; print $1"."$2"."$3}')"
else
# Print error for unknown versioning type
printf "\nError: invalid VERSION_TYPE arg passed, must be 'patch', 'minor' or 'major'\n\n"
# Exit with error code
exit 1
fi
# Update MacOS version.
sed -i '' -e 's/'"$GIT_TAG_LATEST"'/'"$VERSION_NEXT"'/' macos/Grim.app/Contents/Info.plist
# Update version for Windows installer.
sed -i '' -e 's/" Version="[^\"]*"/" Version="'"$VERSION_NEXT"'"/g' wix/main.wxs
sed -i '' -e 's/<Package Id="[^\"]*"/<Package Id="'"$(uuidgen)"'"/g' wix/main.wxs
# Update Android version in build.gradle
sed -i'.bak' -e 's/versionName [0-9a-zA-Z -_]*/versionName "'"$VERSION_NEXT"'"/' android/app/build.gradle
rm -f android/app/build.gradle.bak
# Update version in Cargo.toml
sed -i'.bak' -e "s/^version = .*/version = \"$VERSION_NEXT\"/" Cargo.toml
rm -f Cargo.toml.bak
# Update Cargo.lock as this changes when
# updating the version in your manifest
cargo update -p grim
# Commit the changes
git add .
git commit -m "build: version $VERSION_NEXT"
# ==================================
# Create git tag for new version
# ==================================
# Create a tag and push to master branch
#git tag "v$VERSION_NEXT" master
#git push origin master --follow-tags
Regular → Executable
+436 -350
View File
@@ -12,408 +12,494 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::atomic::{AtomicBool, Ordering};
use egui::epaint::RectShape;
use egui::{
Align, Context, CornerRadius, CursorIcon, LayerId, Layout, Modifiers, Order, ResizeDirection,
Stroke, StrokeKind, UiBuilder, ViewportCommand,
};
use lazy_static::lazy_static;
use egui::{Align, Context, CursorIcon, Layout, Modifiers, Rect, ResizeDirection, Rounding, Stroke, ViewportCommand};
use egui::epaint::{RectShape};
use egui::os::OperatingSystem;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::AppConfig;
use crate::gui::Colors;
use crate::gui::icons::{ARROWS_IN, ARROWS_OUT, CARET_DOWN, MOON, SUN, X};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Content, TitlePanel, View};
use crate::gui::views::types::ContentContainer;
use crate::gui::views::{Content, KeyboardContent, Modal, TitlePanel, View};
lazy_static! {
/// State to check if platform Back button was pressed.
static ref BACK_BUTTON_PRESSED: AtomicBool = AtomicBool::new(false);
/// State to check if platform Back button was pressed.
static ref BACK_BUTTON_PRESSED: AtomicBool = AtomicBool::new(false);
}
/// Implements ui entry point and contains platform-specific callbacks.
pub struct App<Platform> {
/// Platform specific callbacks handler.
pub(crate) platform: Platform,
/// Handles platform-specific functionality.
pub platform: Platform,
/// Main ui content.
content: Content,
/// Main content.
content: Content,
/// Last window resize direction.
resize_direction: Option<ResizeDirection>
/// Last window resize direction.
resize_direction: Option<ResizeDirection>,
/// Flag to check if it's first draw.
first_draw: bool,
/// Last status-bar icon state pushed to the platform (Android).
status_bar_white: Option<bool>,
}
impl<Platform: PlatformCallbacks> App<Platform> {
pub fn new(platform: Platform) -> Self {
Self { platform, content: Content::default(), resize_direction: None }
}
pub fn new(platform: Platform) -> Self {
Self {
platform,
content: Content::default(),
resize_direction: None,
first_draw: true,
status_bar_white: None,
}
}
/// Draw application content.
pub fn ui(&mut self, ctx: &Context) {
// Handle Esc keyboard key event and platform Back button key event.
let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed);
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::Escape)) || back_pressed {
self.content.on_back();
if back_pressed {
BACK_BUTTON_PRESSED.store(false, Ordering::Relaxed);
}
// Request repaint to update previous content.
ctx.request_repaint();
}
/// Called of first content draw.
fn on_first_draw(&mut self, ctx: &Context) {
// Set platform context.
if View::is_desktop() {
self.platform.set_context(ctx);
}
// Setup visuals.
crate::setup_fonts(ctx);
crate::setup_visuals(ctx);
}
// Handle Close event (on desktop).
if ctx.input(|i| i.viewport().close_requested()) {
if !self.content.exit_allowed {
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
Content::show_exit_modal();
} else {
ctx.input(|i| {
if let Some(rect) = i.viewport().inner_rect {
AppConfig::save_window_size(rect.width(), rect.height());
}
if let Some(rect) = i.viewport().outer_rect {
AppConfig::save_window_pos(rect.left(), rect.top());
}
});
}
}
/// Draw application content.
pub fn ui(&mut self, ctx: &Context) {
if self.first_draw {
self.on_first_draw(ctx);
self.first_draw = false;
}
// Show main content with custom frame on desktop.
egui::CentralPanel::default()
.frame(egui::Frame {
..Default::default()
})
.show(ctx, |ui| {
let is_mac_os = OperatingSystem::from_target_os() == OperatingSystem::Mac;
if View::is_desktop() && !is_mac_os {
self.desktop_window_ui(ui);
} else {
if is_mac_os {
self.window_title_ui(ui);
ui.add_space(-1.0);
}
self.content.ui(ui, &self.platform);
}
});
}
// 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));
/// Draw custom resizeable window content.
fn desktop_window_ui(&mut self, ui: &mut egui::Ui) {
let is_fullscreen = ui.ctx().input(|i| {
i.viewport().fullscreen.unwrap_or(false)
});
// Keep the Android status-bar icons readable against the in-app theme
// (the app draws edge-to-edge over the status bar). Only on change.
let white_icons = crate::gui::theme::status_bar_white_icons();
if self.status_bar_white != Some(white_icons) {
self.platform.set_status_bar_white_icons(white_icons);
self.status_bar_white = Some(white_icons);
}
let title_stroke_rect = {
let mut rect = ui.max_rect();
if !is_fullscreen {
rect = rect.shrink(Content::WINDOW_FRAME_MARGIN);
}
rect.max.y = if !is_fullscreen {
Content::WINDOW_FRAME_MARGIN
} else {
0.0
} + Content::WINDOW_TITLE_HEIGHT + TitlePanel::DEFAULT_HEIGHT + 0.5;
rect
};
let title_stroke = RectShape {
rect: title_stroke_rect,
rounding: Rounding {
nw: 8.0,
ne: 8.0,
sw: 0.0,
se: 0.0,
},
fill: Colors::yellow(),
stroke: Stroke {
width: 1.0,
color: egui::Color32::from_gray(200)
},
blur_width: 0.0,
fill_texture_id: Default::default(),
uv: Rect::ZERO
};
// Draw title stroke.
ui.painter().add(title_stroke);
// Handle Esc keyboard key event and platform Back button key event.
let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed);
if back_pressed
|| ctx.input_mut(|i| {
i.consume_key(Modifiers::NONE, egui::Key::Escape)
|| i.consume_key(Modifiers::NONE, egui::Key::BrowserBack)
}) {
// Pass event to content.
self.content.on_back(ctx, &self.platform);
if back_pressed {
BACK_BUTTON_PRESSED.store(false, Ordering::Relaxed);
}
// Request repaint to update previous content.
ctx.request_repaint();
}
let content_stroke_rect = {
let mut rect = ui.max_rect();
if !is_fullscreen {
rect = rect.shrink(Content::WINDOW_FRAME_MARGIN);
}
let top = Content::WINDOW_TITLE_HEIGHT + TitlePanel::DEFAULT_HEIGHT + 0.5;
rect.min += egui::vec2(0.0, top);
rect
};
let content_stroke = RectShape {
rect: content_stroke_rect,
rounding: Rounding::ZERO,
fill: Colors::fill(),
stroke: Stroke {
width: 1.0,
color: Colors::stroke()
},
blur_width: 0.0,
fill_texture_id: Default::default(),
uv: Rect::ZERO
};
// Draw content stroke.
ui.painter().add(content_stroke);
// Handle Close event on desktop.
if View::is_desktop() && ctx.input(|i| i.viewport().close_requested()) {
if !self.content.exit_allowed {
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
Content::show_exit_modal();
} else {
let (w, h) = View::window_size(ctx);
AppConfig::save_window_size(w, h);
ctx.input(|i| {
if let Some(rect) = i.viewport().outer_rect {
AppConfig::save_window_pos(rect.left(), rect.top());
}
});
}
}
// Draw window content.
let mut content_rect = ui.max_rect();
if !is_fullscreen {
content_rect = content_rect.shrink(Content::WINDOW_FRAME_MARGIN);
}
ui.allocate_ui_at_rect(content_rect, |ui| {
self.window_title_ui(ui);
self.window_content(ui);
});
// Show main content.
egui::CentralPanel::default()
.frame(egui::Frame {
..Default::default()
})
.show(ctx, |ui| {
if View::is_desktop() {
let is_fullscreen =
ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false));
let os = egui::os::OperatingSystem::from_target_os();
match os {
egui::os::OperatingSystem::Mac => {
self.window_title_ui(ui, is_fullscreen);
ui.add_space(-1.0);
Self::title_panel_bg(ui, true);
self.content.ui(ui, &self.platform);
}
egui::os::OperatingSystem::Windows => {
Self::title_panel_bg(ui, false);
self.content.ui(ui, &self.platform);
}
_ => {
self.custom_frame_ui(ui, is_fullscreen);
}
}
} else {
Self::title_panel_bg(ui, false);
self.content.ui(ui, &self.platform);
}
});
// Setup resize areas.
if !is_fullscreen {
self.resize_area_ui(ui, ResizeDirection::North);
self.resize_area_ui(ui, ResizeDirection::East);
self.resize_area_ui(ui, ResizeDirection::South);
self.resize_area_ui(ui, ResizeDirection::West);
self.resize_area_ui(ui, ResizeDirection::NorthWest);
self.resize_area_ui(ui, ResizeDirection::NorthEast);
self.resize_area_ui(ui, ResizeDirection::SouthEast);
self.resize_area_ui(ui, ResizeDirection::SouthWest);
}
}
// Check if desktop window was focused after requested attention.
if self.platform.user_attention_required()
&& ctx.input(|i| i.viewport().focused.unwrap_or(true))
{
self.platform.clear_user_attention();
}
/// Draw window content for desktop.
fn window_content(&mut self, ui: &mut egui::Ui) {
let content_rect = {
let mut rect = ui.max_rect();
rect.min.y += Content::WINDOW_TITLE_HEIGHT;
rect
};
// Draw main content.
let mut content_ui = ui.child_ui(content_rect, *ui.layout(), None);
self.content.ui(&mut content_ui, &self.platform);
}
// Show modal or keyboard window above opened Modal.
if Modal::opened().is_some() {
ctx.move_to_top(LayerId::new(Order::Middle, egui::Id::new(Modal::WINDOW_ID)));
let keyboard_showing = if let Some(l) = ctx.top_layer_id() {
l.id == egui::Id::new(KeyboardContent::WINDOW_ID)
} else {
false
};
if keyboard_showing {
ctx.move_to_top(LayerId::new(
Order::Middle,
egui::Id::new(KeyboardContent::WINDOW_ID),
));
}
}
// Reset keyboard state for newly opened modal.
if Modal::first_draw() {
KeyboardContent::reset_window_state();
}
}
/// Draw custom window title content.
fn window_title_ui(&self, ui: &mut egui::Ui) {
let content_rect = ui.max_rect();
/// Draw custom desktop window frame content.
fn custom_frame_ui(&mut self, ui: &mut egui::Ui, is_fullscreen: bool) {
// Paint the window area inside the frame margin with the theme
// background first: surface gaps are otherwise transparent, which X11
// without a compositor renders as black (strip under the sidebar in
// light/yellow themes). The margin ring itself must STAY transparent —
// painting it gives the window a visible border under a compositor.
let fill_rect = if is_fullscreen {
ui.max_rect()
} else {
ui.max_rect().shrink(Content::WINDOW_FRAME_MARGIN)
};
let fill_rounding = if is_fullscreen {
CornerRadius::ZERO
} else {
CornerRadius {
nw: 8,
ne: 8,
sw: 0,
se: 0,
}
};
ui.painter()
.rect_filled(fill_rect, fill_rounding, Colors::fill());
let content_bg_rect = {
let mut r = ui.max_rect();
if !is_fullscreen {
r = r.shrink(Content::WINDOW_FRAME_MARGIN);
}
r.min.y += Content::WINDOW_TITLE_HEIGHT + TitlePanel::HEIGHT;
r
};
let content_bg = RectShape::new(
content_bg_rect,
CornerRadius::ZERO,
Colors::fill_lite(),
View::default_stroke(),
StrokeKind::Outside,
);
// Draw content background.
ui.painter().add(content_bg);
let title_rect = {
let mut rect = content_rect;
rect.max.y = rect.min.y + Content::WINDOW_TITLE_HEIGHT;
rect
};
let mut content_rect = ui.max_rect();
if !is_fullscreen {
content_rect = content_rect.shrink(Content::WINDOW_FRAME_MARGIN);
}
// Draw window content.
ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| {
// Draw window title.
self.window_title_ui(ui, is_fullscreen);
ui.add_space(-1.0);
let is_fullscreen = ui.ctx().input(|i| {
i.viewport().fullscreen.unwrap_or(false)
});
// Draw title panel background.
Self::title_panel_bg(ui, true);
let window_title_bg = RectShape {
rect: title_rect,
rounding: if is_fullscreen {
Rounding::ZERO
} else {
Rounding {
nw: 8.0,
ne: 8.0,
sw: 0.0,
se: 0.0,
}
},
fill: Colors::yellow_dark(),
stroke: Stroke::NONE,
blur_width: 0.0,
fill_texture_id: Default::default(),
uv: Rect::ZERO
};
// Draw title background.
ui.painter().add(window_title_bg);
let content_rect = {
let mut rect = ui.max_rect();
rect.min.y += Content::WINDOW_TITLE_HEIGHT;
rect
};
let mut content_ui =
ui.new_child(UiBuilder::new().max_rect(content_rect).layout(*ui.layout()));
// Draw main content.
self.content.ui(&mut content_ui, &self.platform);
});
let painter = ui.painter();
// Setup resize areas.
if !is_fullscreen {
self.resize_area_ui(ui, ResizeDirection::North);
self.resize_area_ui(ui, ResizeDirection::East);
self.resize_area_ui(ui, ResizeDirection::South);
self.resize_area_ui(ui, ResizeDirection::West);
self.resize_area_ui(ui, ResizeDirection::NorthWest);
self.resize_area_ui(ui, ResizeDirection::NorthEast);
self.resize_area_ui(ui, ResizeDirection::SouthEast);
self.resize_area_ui(ui, ResizeDirection::SouthWest);
}
}
let interact_rect = {
let mut rect = title_rect;
if !is_fullscreen {
rect.min.y += Content::WINDOW_FRAME_MARGIN;
}
rect
};
let title_resp = ui.interact(
interact_rect,
egui::Id::new("window_title"),
egui::Sense::click_and_drag(),
);
/// Draw title panel background.
fn title_panel_bg(ui: &mut egui::Ui, window_title: bool) {
let title_rect = {
let mut rect = ui.max_rect();
if window_title {
rect.min.y += Content::WINDOW_TITLE_HEIGHT - 0.5;
}
rect.max.y = rect.min.y + View::get_top_inset() + TitlePanel::HEIGHT;
rect
};
let title_bg = RectShape::filled(title_rect, CornerRadius::ZERO, Colors::yellow());
ui.painter().add(title_bg);
}
// Paint the title.
let dual_wallets_panel =
ui.available_width() >= (Content::SIDE_PANEL_WIDTH * 3.0) + View::get_right_inset();
let wallet_panel_opened = self.content.wallets.wallet_panel_opened();
let hide_app_name = if dual_wallets_panel {
!wallet_panel_opened || (AppConfig::show_wallets_at_dual_panel() &&
self.content.wallets.showing_wallet() && !self.content.wallets.creating_wallet())
} else if Content::is_dual_panel_mode(ui) {
!wallet_panel_opened
} else {
!Content::is_network_panel_open() && !wallet_panel_opened
};
let title_text = if hide_app_name {
"".to_string()
} else {
format!("Grim {}", crate::VERSION)
};
painter.text(
title_rect.center(),
egui::Align2::CENTER_CENTER,
title_text,
egui::FontId::proportional(15.0),
Colors::title(true),
);
/// Draw custom window title content.
fn window_title_ui(&self, ui: &mut egui::Ui, is_fullscreen: bool) {
let title_rect = {
let mut rect = ui.max_rect();
rect.max.y = rect.min.y + Content::WINDOW_TITLE_HEIGHT;
rect
};
// Interact with the window title (drag to move window):
if !is_fullscreen && title_resp.double_clicked() {
ui.ctx().send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen));
}
let title_bg_rect = {
let mut r = title_rect.clone();
r.max.y += TitlePanel::HEIGHT - 1.0;
r
};
let is_mac = egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac;
let window_title_bg = RectShape::new(
title_bg_rect,
if is_fullscreen || is_mac {
CornerRadius::ZERO
} else {
CornerRadius {
nw: 8.0 as u8,
ne: 8.0 as u8,
sw: 0.0 as u8,
se: 0.0 as u8,
}
},
Colors::yellow_dark(),
Stroke::new(1.0, Colors::STROKE),
StrokeKind::Outside,
);
// Draw title background.
ui.painter().add(window_title_bg);
if !is_fullscreen && title_resp.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
}
let painter = ui.painter();
ui.allocate_ui_at_rect(title_rect, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Draw button to close window.
View::title_button_small(ui, X, |_| {
Content::show_exit_modal();
});
let interact_rect = {
let mut rect = title_rect.clone();
rect.max.x -= 128.0;
rect.min.x += 85.0;
if !is_fullscreen {
rect.min.y += Content::WINDOW_FRAME_MARGIN;
}
rect
};
let title_resp = ui.interact(
interact_rect,
egui::Id::new("window_title"),
egui::Sense::drag(),
);
// Interact with the window title (drag to move window):
if !is_fullscreen && title_resp.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
}
// Draw fullscreen button.
let fullscreen_icon = if is_fullscreen {
ARROWS_IN
} else {
ARROWS_OUT
};
View::title_button_small(ui, fullscreen_icon, |ui| {
ui.ctx().send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen));
});
// 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);
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,
);
// Draw button to minimize window.
View::title_button_small(ui, CARET_DOWN, |ui| {
ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true));
});
ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Draw button to close window.
View::title_button_small(ui, X, |_| {
if Modal::opened().is_none() || Modal::opened_closeable() {
Content::show_exit_modal();
}
});
// Draw application icon.
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| {
// Draw button to minimize window.
let use_dark = AppConfig::dark_theme().unwrap_or(false);
let theme_icon = if use_dark {
SUN
} else {
MOON
};
View::title_button_small(ui, theme_icon, |ui| {
AppConfig::set_dark_theme(!use_dark);
crate::setup_visuals(ui.ctx());
});
});
});
});
}
// Draw fullscreen button.
let fullscreen_icon = if is_fullscreen { ARROWS_IN } else { ARROWS_OUT };
View::title_button_small(ui, fullscreen_icon, |ui| {
ui.ctx()
.send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen));
});
/// Setup window resize area.
fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) {
let mut rect = ui.max_rect();
// Draw button to minimize window.
View::title_button_small(ui, CARET_DOWN, |ui| {
ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true));
});
// Setup area id, cursor and area rect based on direction.
let (id, cursor, rect) = match direction {
ResizeDirection::North => ("n", CursorIcon::ResizeNorth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::East => ("e", CursorIcon::ResizeEast, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::South => ("s", CursorIcon::ResizeSouth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::West => ("w", CursorIcon::ResizeWest, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::NorthWest => ("nw", CursorIcon::ResizeNorthWest, {
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.max.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::NorthEast => ("ne", CursorIcon::ResizeNorthEast, {
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::SouthEast => ("se", CursorIcon::ResizeSouthEast, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::SouthWest => ("sw", CursorIcon::ResizeSouthWest, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
};
// Draw application icon.
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(
layout_size,
Layout::left_to_right(Align::Center),
|ui| {
// Draw button to minimize window.
let use_dark = AppConfig::dark_theme().unwrap_or(false);
let theme_icon = if use_dark { SUN } else { MOON };
View::title_button_small(ui, theme_icon, |ui| {
AppConfig::set_dark_theme(!use_dark);
crate::setup_visuals(ui.ctx());
});
},
);
});
});
}
// Setup resize area.
let id = egui::Id::new("window_resize").with(id);
let sense = egui::Sense::drag();
let area_resp = ui.interact(rect, id, sense).on_hover_cursor(cursor);
if area_resp.dragged() {
if self.resize_direction.is_none() {
self.resize_direction = Some(direction.clone());
ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction));
}
}
if area_resp.drag_stopped() {
self.resize_direction = None;
}
}
/// Setup window resize area.
fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) {
let mut rect = ui.max_rect();
// Setup area id, cursor and area rect based on direction.
let (id, cursor, rect) = match direction {
ResizeDirection::North => ("n", CursorIcon::ResizeNorth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::East => ("e", CursorIcon::ResizeEast, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::South => ("s", CursorIcon::ResizeSouth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::West => ("w", CursorIcon::ResizeWest, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::NorthWest => ("nw", CursorIcon::ResizeNorthWest, {
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.max.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::NorthEast => ("ne", CursorIcon::ResizeNorthEast, {
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::SouthEast => ("se", CursorIcon::ResizeSouthEast, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::SouthWest => ("sw", CursorIcon::ResizeSouthWest, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
};
// Setup resize area.
let id = egui::Id::new("window_resize").with(id);
let sense = egui::Sense::drag();
let area_resp = ui.interact(rect, id, sense).on_hover_cursor(cursor);
if area_resp.dragged() {
if self.resize_direction.is_none() {
self.resize_direction = Some(direction.clone());
ui.ctx()
.send_viewport_cmd(ViewportCommand::BeginResize(direction));
}
}
if area_resp.drag_stopped() {
self.resize_direction = None;
}
}
}
/// To draw with egui`s eframe (for wgpu, glow backends and wasm target).
impl<Platform: PlatformCallbacks> eframe::App for App<Platform> {
fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) {
self.ui(ctx);
}
fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) {
ctx.plugin_or_default::<egui_async::EguiAsyncPlugin>();
self.ui(ctx);
}
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
if View::is_desktop() {
let is_mac_os = OperatingSystem::from_target_os() == OperatingSystem::Mac;
if is_mac_os {
Colors::fill().to_normalized_gamma_f32()
} else {
egui::Rgba::TRANSPARENT.to_array()
}
} else {
Colors::fill().to_normalized_gamma_f32()
}
}
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
let os = egui::os::OperatingSystem::from_target_os();
let is_win = os == egui::os::OperatingSystem::Windows;
let is_mac = os == egui::os::OperatingSystem::Mac;
if !View::is_desktop() || is_win || is_mac {
return Colors::fill_lite().to_normalized_gamma_f32();
}
Colors::TRANSPARENT.to_normalized_gamma_f32()
}
}
#[allow(dead_code)]
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
#[no_mangle]
#[unsafe(no_mangle)]
/// Handle Back key code event from Android.
pub extern "C" fn Java_mw_gri_android_MainActivity_onBack(
_env: jni::JNIEnv,
_class: jni::objects::JObject,
_activity: jni::objects::JObject,
_env: jni::JNIEnv,
_class: jni::objects::JObject,
_activity: jni::objects::JObject,
) {
BACK_BUTTON_PRESSED.store(true, Ordering::Relaxed);
}
BACK_BUTTON_PRESSED.store(true, Ordering::Relaxed);
}
+120 -206
View File
@@ -1,4 +1,5 @@
// Copyright 2023 The Grim Developers
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,242 +13,155 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Legacy color API mapped onto the Goblin design tokens in [`crate::gui::theme`].
//! Existing call sites keep compiling; everything sources from the active theme.
use egui::Color32;
use crate::AppConfig;
use crate::gui::theme;
/// Provides colors values based on current theme.
/// Provides color values based on the current theme tokens.
pub struct Colors;
const WHITE: Color32 = Color32::from_gray(253);
const BLACK: Color32 = Color32::from_gray(12);
const SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(100);
const DARK_SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(170);
const GOLD: Color32 = Color32::from_rgb(255, 215, 0);
const INK: Color32 = Color32::from_rgb(0x0E, 0x0E, 0x0C);
const PAPER: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xF7);
const YELLOW: Color32 = Color32::from_rgb(254, 241, 2);
const YELLOW_DARK: Color32 = Color32::from_rgb(239, 229, 3);
const GREEN: Color32 = Color32::from_rgb(0, 0x64, 0);
const RED: Color32 = Color32::from_rgb(0x8B, 0, 0);
const BLUE: Color32 = Color32::from_rgb(0, 0x66, 0xE4);
const FILL: Color32 = Color32::from_gray(244);
const FILL_DARK: Color32 = Color32::from_gray(24);
const FILL_DEEP: Color32 = Color32::from_gray(238);
const FILL_DEEP_DARK: Color32 = Color32::from_gray(18);
const TEXT: Color32 = Color32::from_gray(80);
const TEXT_DARK: Color32 = Color32::from_gray(185);
const CHECKBOX: Color32 = Color32::from_gray(100);
const CHECKBOX_DARK: Color32 = Color32::from_gray(175);
const TEXT_BUTTON: Color32 = Color32::from_gray(70);
const TEXT_BUTTON_DARK: Color32 = Color32::from_gray(195);
const TITLE: Color32 = Color32::from_gray(60);
const TITLE_DARK: Color32 = Color32::from_gray(205);
const BUTTON: Color32 = Color32::from_gray(249);
const BUTTON_DARK: Color32 = Color32::from_gray(16);
const GRAY: Color32 = Color32::from_gray(120);
const GRAY_DARK: Color32 = Color32::from_gray(145);
const STROKE: Color32 = Color32::from_gray(200);
const STROKE_DARK: Color32 = Color32::from_gray(50);
const INACTIVE_TEXT: Color32 = Color32::from_gray(150);
const INACTIVE_TEXT_DARK: Color32 = Color32::from_gray(115);
const ITEM_BUTTON: Color32 = Color32::from_gray(90);
const ITEM_BUTTON_DARK: Color32 = Color32::from_gray(175);
const ITEM_STROKE: Color32 = Color32::from_gray(220);
const ITEM_STROKE_DARK: Color32 = Color32::from_gray(40);
const ITEM_HOVER: Color32 = Color32::from_gray(205);
const ITEM_HOVER_DARK: Color32 = Color32::from_gray(48);
/// Check if dark theme should be used.
fn use_dark() -> bool {
AppConfig::dark_theme().unwrap_or(false)
fn dark_base() -> bool {
theme::tokens().dark_base
}
impl Colors {
pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0);
pub const FILL_DEEP: Color32 = Color32::from_rgb(0xF2, 0xF1, 0xEC);
pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0);
pub const STROKE: Color32 = Color32::from_rgba_premultiplied(1, 1, 1, 20);
pub fn white_or_black(black_in_white: bool) -> Color32 {
if use_dark() {
if black_in_white {
WHITE
} else {
BLACK
}
} else {
if black_in_white {
BLACK
} else {
WHITE
}
}
}
/// Ink when `true`, paper when `false` (theme aware: maps to text/bg).
pub fn white_or_black(black_in_white: bool) -> Color32 {
let t = theme::tokens();
if black_in_white { t.text } else { t.bg }
}
pub fn semi_transparent() -> Color32 {
if use_dark() {
DARK_SEMI_TRANSPARENT
} else {
SEMI_TRANSPARENT
}
}
pub fn semi_transparent() -> Color32 {
if dark_base() {
DARK_SEMI_TRANSPARENT
} else {
SEMI_TRANSPARENT
}
}
pub fn gold() -> Color32 {
if use_dark() {
GOLD.gamma_multiply(0.9)
} else {
GOLD
}
}
pub fn gold() -> Color32 {
theme::tokens().accent
}
pub fn yellow() -> Color32 {
YELLOW
}
pub fn gold_dark() -> Color32 {
theme::tokens().accent_dark
}
pub fn yellow_dark() -> Color32 {
YELLOW_DARK
}
pub fn yellow() -> Color32 {
theme::tokens().accent
}
pub fn green() -> Color32 {
if use_dark() {
GREEN.gamma_multiply(1.3)
} else {
GREEN
}
}
pub fn yellow_dark() -> Color32 {
theme::tokens().accent_dark
}
pub fn red() -> Color32 {
if use_dark() {
RED.gamma_multiply(1.3)
} else {
RED
}
}
/// Ink color to draw on top of accent fills.
pub fn accent_ink() -> Color32 {
theme::tokens().accent_ink
}
pub fn blue() -> Color32 {
if use_dark() {
BLUE.gamma_multiply(1.3)
} else {
BLUE
}
}
pub fn green() -> Color32 {
theme::tokens().pos
}
pub fn fill() -> Color32 {
if use_dark() {
FILL_DARK
} else {
FILL
}
}
pub fn red() -> Color32 {
theme::tokens().neg
}
pub fn fill_deep() -> Color32 {
if use_dark() {
FILL_DEEP_DARK
} else {
FILL_DEEP
}
}
pub fn blue() -> Color32 {
if dark_base() {
Color32::from_rgb(0x7B, 0xA7, 0xFF)
} else {
Color32::from_rgb(0x0E, 0x62, 0xD0)
}
}
pub fn checkbox() -> Color32 {
if use_dark() {
CHECKBOX_DARK
} else {
CHECKBOX
}
}
pub fn fill() -> Color32 {
theme::tokens().bg
}
pub fn text(always_light: bool) -> Color32 {
if use_dark() && !always_light {
TEXT_DARK
} else {
TEXT
}
}
pub fn fill_deep() -> Color32 {
theme::tokens().surface2
}
pub fn text_button() -> Color32 {
if use_dark() {
TEXT_BUTTON_DARK
} else {
TEXT_BUTTON
}
}
pub fn fill_lite() -> Color32 {
theme::tokens().surface
}
pub fn title(always_light: bool) -> Color32 {
if use_dark() && !always_light {
TITLE_DARK
} else {
TITLE
}
}
pub fn checkbox() -> Color32 {
theme::tokens().text_dim
}
pub fn button() -> Color32 {
if use_dark() {
BUTTON_DARK
} else {
BUTTON
}
}
pub fn text(always_light: bool) -> Color32 {
if always_light {
// Forced light-theme ink, used over always-light surfaces like QR cards.
Color32::from_rgb(0x6B, 0x6A, 0x63)
} else {
theme::tokens().text_dim
}
}
pub fn gray() -> Color32 {
if use_dark() {
GRAY_DARK
} else {
GRAY
}
}
pub fn text_button() -> Color32 {
theme::tokens().text
}
pub fn stroke() -> Color32 {
if use_dark() {
STROKE_DARK
} else {
STROKE
}
}
pub fn title(always_light: bool) -> Color32 {
if always_light {
INK
} else {
theme::tokens().text
}
}
pub fn inactive_text() -> Color32 {
if use_dark() {
INACTIVE_TEXT_DARK
} else {
INACTIVE_TEXT
}
}
pub fn gray() -> Color32 {
theme::tokens().text_mute
}
pub fn item_button() -> Color32 {
if use_dark() {
ITEM_BUTTON_DARK
} else {
ITEM_BUTTON
}
}
pub fn stroke() -> Color32 {
theme::tokens().line
}
pub fn item_stroke() -> Color32 {
if use_dark() {
ITEM_STROKE_DARK
} else {
ITEM_STROKE
}
}
pub fn inactive_text() -> Color32 {
theme::tokens().text_mute
}
pub fn item_hover() -> Color32 {
if use_dark() {
ITEM_HOVER_DARK
} else {
ITEM_HOVER
}
}
}
pub fn item_button_text() -> Color32 {
theme::tokens().text_dim
}
pub fn item_stroke() -> Color32 {
theme::tokens().line
}
pub fn item_hover() -> Color32 {
theme::tokens().hover
}
/// Positive amount color.
pub fn pos() -> Color32 {
theme::tokens().pos
}
/// Always-dark ink (brand black).
pub const fn ink() -> Color32 {
INK
}
/// Always-light paper (brand white).
pub const fn paper() -> Color32 {
PAPER
}
}
+3 -2
View File
@@ -12,13 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
mod app;
pub use app::App;
mod colors;
pub use colors::Colors;
pub mod theme;
pub mod icons;
pub mod platform;
pub mod views;
pub mod icons;
+331 -149
View File
@@ -12,14 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use lazy_static::lazy_static;
use parking_lot::RwLock;
use std::env;
use std::ffi::OsString;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use lazy_static::lazy_static;
use std::sync::Arc;
use parking_lot::RwLock;
use jni::JNIEnv;
use jni::objects::{JByteArray, JObject, JString, JValue};
@@ -30,185 +30,367 @@ use crate::gui::platform::PlatformCallbacks;
/// Android platform implementation.
#[derive(Clone)]
pub struct Android {
android_app: AndroidApp,
/// Android related state.
android_app: AndroidApp,
/// Context to repaint content and handle viewport commands.
ctx: Arc<RwLock<Option<egui::Context>>>,
}
impl Android {
/// Create new Android platform instance from provided [`AndroidApp`].
pub fn new(app: AndroidApp) -> Self {
Self {
android_app: app,
}
}
/// Create new Android platform instance from provided [`AndroidApp`].
pub fn new(app: AndroidApp) -> Self {
// 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)),
}
}
/// Call Android Activity method with JNI.
pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option<jni::sys::jvalue> {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let activity = unsafe {
JObject::from_raw(self.android_app.activity_as_ptr() as jni::sys::jobject)
};
if let Ok(result) = env.call_method(activity, name, s, a) {
return Some(result.as_jni().clone());
}
None
}
/// Call Android Activity method with JNI.
pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option<jni::sys::jvalue> {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let activity =
unsafe { JObject::from_raw(self.android_app.activity_as_ptr() as jni::sys::jobject) };
if let Ok(result) = env.call_method(activity, name, s, a) {
return Some(result.as_jni().clone());
}
None
}
}
impl PlatformCallbacks for Android {
fn show_keyboard(&self) {
// Disable NDK soft input show call before fix for egui.
// self.android_app.show_soft_input(false);
fn set_context(&mut self, ctx: &egui::Context) {
let mut w_ctx = self.ctx.write();
*w_ctx = Some(ctx.clone());
}
self.call_java_method("showKeyboard", "()V", &[]).unwrap();
}
fn exit(&self) {
let _ = self.call_java_method("exit", "()V", &[]);
}
fn hide_keyboard(&self) {
// Disable NDK soft input hide call before fix for egui.
// self.android_app.hide_soft_input(false);
fn copy_string_to_buffer(&self, data: String) {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(data).unwrap();
let _ = self.call_java_method(
"copyText",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))],
);
}
self.call_java_method("hideKeyboard", "()V", &[]).unwrap();
}
fn get_string_from_buffer(&self) -> String {
let result = self
.call_java_method("pasteText", "()Ljava/lang/String;", &[])
.unwrap();
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let j_object: jni::sys::jobject = unsafe { result.l };
let paste_data: String = unsafe {
env.get_string(JString::from(JObject::from_raw(j_object)).as_ref())
.unwrap()
.into()
};
paste_data
}
fn copy_string_to_buffer(&self, data: String) {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(data).unwrap();
self.call_java_method("copyText",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))]).unwrap();
}
fn start_camera(&self) {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
// Start camera.
let _ = self.call_java_method("startCamera", "()V", &[]);
}
fn get_string_from_buffer(&self) -> String {
let result = self.call_java_method("pasteText", "()Ljava/lang/String;", &[]).unwrap();
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let j_object: jni::sys::jobject = unsafe { result.l };
let paste_data: String = unsafe {
env.get_string(JString::from(JObject::from_raw(j_object)).as_ref()).unwrap().into()
};
paste_data
}
fn stop_camera(&self) {
// Stop camera.
let _ = self.call_java_method("stopCamera", "()V", &[]);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
}
fn start_camera(&self) {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
// Start camera.
self.call_java_method("startCamera", "()V", &[]).unwrap();
}
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
let r_image = LAST_CAMERA_IMAGE.read();
if r_image.is_some() {
return Some(r_image.clone().unwrap());
}
None
}
fn stop_camera(&self) {
// Stop camera.
self.call_java_method("stopCamera", "()V", &[]).unwrap();
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
}
fn can_switch_camera(&self) -> bool {
if let Some(res) = self.call_java_method("camerasAmount", "()I", &[]) {
let amount = unsafe { res.i };
return amount > 1;
}
false
}
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
let r_image = LAST_CAMERA_IMAGE.read();
if r_image.is_some() {
return Some(r_image.clone().unwrap());
}
None
}
fn switch_camera(&self) {
let _ = self.call_java_method("switchCamera", "()V", &[]);
}
fn can_switch_camera(&self) -> bool {
let result = self.call_java_method("camerasAmount", "()I", &[]).unwrap();
let amount = unsafe { result.i };
amount > 1
}
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
let default_cache = OsString::from(dirs::cache_dir().unwrap());
let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache));
// File path for Android provider.
file.push("share");
if !file.exists() {
std::fs::create_dir(file.clone())?;
}
file.push(name);
if file.exists() {
std::fs::remove_file(file.clone())?;
}
let mut image = File::create_new(file.clone())?;
image.write_all(data.as_slice())?;
image.sync_all()?;
// Call share modal at system.
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(file.to_str().unwrap()).unwrap();
let _ = self.call_java_method(
"shareData",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))],
);
Ok(())
}
fn switch_camera(&self) {
self.call_java_method("switchCamera", "()V", &[]).unwrap();
}
fn 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_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
// Create file at cache dir.
let default_cache = OsString::from(dirs::cache_dir().unwrap());
let mut cache = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache));
cache.push("images");
std::fs::create_dir_all(cache.to_str().unwrap())?;
cache.push(name);
let mut image = File::create_new(cache.clone()).unwrap();
image.write_all(data.as_slice()).unwrap();
image.sync_all().unwrap();
// Call share modal at system.
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(cache.to_str().unwrap()).unwrap();
self.call_java_method("shareImage",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))]).unwrap();
Ok(())
}
fn share_text(&self, text: String) {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let Ok(arg_value) = env.new_string(text) else {
return;
};
let _ = self.call_java_method(
"shareText",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))],
);
}
fn pick_file(&self) -> Option<String> {
// Clear previous result.
let mut w_path = PICKED_FILE_PATH.write();
*w_path = None;
// Launch file picker.
let _ = self.call_java_method("pickFile", "()V", &[]).unwrap();
// Return empty string to identify async pick.
Some("".to_string())
}
fn pick_file(&self) -> Option<String> {
// Clear previous result.
let mut w_path = PICKED_FILE_PATH.write();
*w_path = None;
// Launch file picker.
let _ = self.call_java_method("pickFile", "()V", &[]);
// Return empty string to identify async pick.
Some("".to_string())
}
fn picked_file(&self) -> Option<String> {
let has_file = {
let r_path = PICKED_FILE_PATH.read();
r_path.is_some()
};
if has_file {
let mut w_path = PICKED_FILE_PATH.write();
let path = Some(w_path.clone().unwrap());
*w_path = None;
return path
}
None
}
fn pick_folder(&self) -> Option<String> {
// Clear previous result.
let mut w_path = PICKED_FILE_PATH.write();
*w_path = None;
// Launch file picker.
let _ = self.call_java_method("pickFolder", "()V", &[]);
// Return empty string to identify async pick.
Some("".to_string())
}
fn picked_file(&self) -> Option<String> {
let has_file = {
let r_path = PICKED_FILE_PATH.read();
r_path.is_some()
};
if has_file {
let mut w_path = PICKED_FILE_PATH.write();
let path = Some(w_path.clone().unwrap());
*w_path = None;
return path;
}
None
}
fn request_user_attention(&self) {}
fn user_attention_required(&self) -> bool {
false
}
fn clear_user_attention(&self) {}
fn set_status_bar_white_icons(&self, white: bool) {
self.call_java_method(
"setStatusBarWhiteIcons",
"(Z)V",
&[JValue::Bool(white as u8)],
);
}
fn vibrate_error(&self) {
let _ = self.call_java_method("vibrateError", "()V", &[]);
}
fn vibrate_copy(&self) {
let _ = self.call_java_method("vibrateCopy", "()V", &[]);
}
}
lazy_static! {
/// Last image data from camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
/// Picked file path.
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
/// Last image data from camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
/// Picked file path.
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
/// 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.
#[allow(non_snake_case)]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage(
env: JNIEnv,
_class: JObject,
buff: jni::sys::jbyteArray,
rotation: jni::sys::jint,
env: JNIEnv,
_class: JObject,
buff: jni::sys::jbyteArray,
rotation: jni::sys::jint,
) {
let arr = unsafe { JByteArray::from_raw(buff) };
let image : Vec<u8> = env.convert_byte_array(arr).unwrap();
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((image, rotation as u32));
let arr = unsafe { JByteArray::from_raw(buff) };
let image: Vec<u8> = env.convert_byte_array(arr).unwrap();
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((image, rotation as u32));
}
/// Callback from Java code with picked file path.
#[allow(non_snake_case)]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn Java_mw_gri_android_MainActivity_onFilePick(
_env: JNIEnv,
_class: JObject,
char: jni::sys::jstring
_env: JNIEnv,
_class: JObject,
char: jni::sys::jstring,
) {
use std::ops::Add;
unsafe {
let j_obj = JString::from_raw(char);
let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap();
match j_str.to_str() {
Ok(str) => {
let mut w_path = PICKED_FILE_PATH.write();
*w_path = Some(w_path.clone().unwrap_or("".to_string()).add(str));
}
Err(_) => {}
}
}
}
use std::ops::Add;
unsafe {
let j_obj = JString::from_raw(char);
let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap();
match j_str.to_str() {
Ok(str) => {
let mut w_path = PICKED_FILE_PATH.write();
*w_path = Some(w_path.clone().unwrap_or("".to_string()).add(str));
}
Err(_) => {}
}
}
}
+324 -171
View File
@@ -12,197 +12,350 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::fs::File;
use std::io:: Write;
use egui::{UserAttentionType, ViewportCommand, WindowLevel};
use lazy_static::lazy_static;
use parking_lot::RwLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use rfd::FileDialog;
use std::fs::File;
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use crate::gui::platform::PlatformCallbacks;
/// Desktop platform related actions.
#[derive(Clone)]
pub struct Desktop {
/// Flag to check if camera stop is needed.
stop_camera: Arc<AtomicBool>,
}
/// Context to repaint content and handle viewport commands.
ctx: Arc<RwLock<Option<egui::Context>>>,
impl Default for Desktop {
fn default() -> Self {
Self {
stop_camera: Arc::new(AtomicBool::new(false)),
}
}
}
/// Cameras amount.
cameras_amount: Arc<AtomicUsize>,
/// Camera index.
camera_index: Arc<AtomicUsize>,
/// Flag to check if camera stop is needed.
stop_camera: Arc<AtomicBool>,
impl PlatformCallbacks for Desktop {
fn show_keyboard(&self) {}
fn hide_keyboard(&self) {}
fn copy_string_to_buffer(&self, data: String) {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.set_text(data).unwrap();
}
fn get_string_from_buffer(&self) -> String {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.get_text().unwrap_or("".to_string())
}
fn start_camera(&self) {
// Clear image.
{
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
}
// Setup stop camera flag.
let stop_camera = self.stop_camera.clone();
stop_camera.store(false, Ordering::Relaxed);
// Capture images at separate thread.
thread::spawn(move || {
Self::start_camera_capture(stop_camera);
});
}
fn stop_camera(&self) {
// Stop camera.
self.stop_camera.store(true, Ordering::Relaxed);
}
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
let r_image = LAST_CAMERA_IMAGE.read();
if r_image.is_some() {
return r_image.clone();
}
None
}
fn can_switch_camera(&self) -> bool {
false
}
fn switch_camera(&self) {
return;
}
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
let folder = FileDialog::new()
.set_title(t!("share"))
.set_directory(dirs::home_dir().unwrap())
.set_file_name(name.clone())
.save_file();
if let Some(folder) = folder {
let mut image = File::create(folder)?;
image.write_all(data.as_slice())?;
image.sync_all()?;
}
Ok(())
}
fn pick_file(&self) -> Option<String> {
let file = FileDialog::new()
.set_title(t!("choose_file"))
.set_directory(dirs::home_dir().unwrap())
.pick_file();
if let Some(file) = file {
return Some(file.to_str().unwrap_or_default().to_string());
}
None
}
fn picked_file(&self) -> Option<String> {
None
}
/// Flag to check if attention required after window focusing.
attention_required: Arc<AtomicBool>,
}
impl Desktop {
#[allow(dead_code)]
#[cfg(target_os = "windows")]
fn start_camera_capture(stop_camera: Arc<AtomicBool>) {
use nokhwa::Camera;
use nokhwa::pixel_format::RgbFormat;
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
let index = CameraIndex::Index(0);
let requested = RequestedFormat::new::<RgbFormat>(
RequestedFormatType::AbsoluteHighestFrameRate
);
// Create and open camera.
let mut camera = Camera::new(index, requested).unwrap();
if let Ok(_) = camera.open_stream() {
loop {
// Stop if camera was stopped.
if stop_camera.load(Ordering::Relaxed) {
stop_camera.store(false, Ordering::Relaxed);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
// Get a frame.
if let Ok(frame) = camera.frame() {
// Save image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((frame.buffer().to_vec(), 0));
} else {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
}
camera.stop_stream().unwrap();
};
}
pub fn new() -> Self {
Self {
ctx: Arc::new(RwLock::new(None)),
cameras_amount: Arc::new(AtomicUsize::new(0)),
camera_index: Arc::new(AtomicUsize::new(0)),
stop_camera: Arc::new(AtomicBool::new(false)),
attention_required: Arc::new(AtomicBool::new(false)),
}
}
#[allow(dead_code)]
#[cfg(not(target_os = "windows"))]
fn start_camera_capture(stop_camera: Arc<AtomicBool>) {
use eye::hal::{traits::{Context, Device, Stream}, PlatformContext};
use image::ImageEncoder;
// #[allow(dead_code)]
#[cfg(not(target_os = "macos"))]
fn start_camera_capture(
cameras_amount: Arc<AtomicUsize>,
camera_index: Arc<AtomicUsize>,
stop_camera: Arc<AtomicBool>,
) {
use nokhwa::Camera;
use nokhwa::pixel_format::RgbFormat;
use nokhwa::utils::ApiBackend;
use nokhwa::utils::{FrameFormat, RequestedFormat, RequestedFormatType};
let ctx = PlatformContext::default();
let devices = ctx.devices().unwrap();
let dev = ctx.open_device(&devices[0].uri).unwrap();
thread::spawn(move || {
// Device enumeration does IO — keep it off the UI thread, and
// treat a backend error the same as "no cameras".
let devices = nokhwa::query(ApiBackend::Auto).unwrap_or_default();
cameras_amount.store(devices.len(), Ordering::Relaxed);
let index = camera_index.load(Ordering::Relaxed);
if devices.is_empty() || index >= devices.len() {
return;
}
let streams = dev.streams().unwrap();
let stream_desc = streams[0].clone();
let w = stream_desc.width;
let h = stream_desc.height;
// Open by the enumerated device's own index, not the list
// position: on v4l they differ whenever /dev/video0 is absent
// (first camera at video1, loopback-only setups, …).
let index = devices[index].index().clone();
let requested =
RequestedFormat::new::<RgbFormat>(RequestedFormatType::AbsoluteHighestFrameRate);
// Create and open camera.
if let Ok(mut camera) = Camera::new(index, requested) {
if let Ok(_) = camera.open_stream() {
loop {
// Stop if camera was stopped.
if stop_camera.load(Ordering::Relaxed) {
stop_camera.store(false, Ordering::Relaxed);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
// Get a frame.
if let Ok(frame) = camera.frame() {
// Consumers expect an encoded image. MJPEG frames
// already are; anything else (YUYV, NV12, …) must
// be decoded to RGB and re-encoded, or the readers
// fail on the raw buffer and show a spinner forever.
let bytes = if frame.source_frame_format() == FrameFormat::MJPEG {
Some(frame.buffer().to_vec())
} else if let Ok(image) = frame.decode_image::<RgbFormat>() {
let mut bytes: Vec<u8> = Vec::new();
image
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Jpeg,
)
.ok()
.map(|_| bytes)
} else {
None
};
if let Some(bytes) = bytes {
// Save image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((bytes, 0));
}
} else {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
}
let _ = camera.stop_stream();
};
}
});
}
let mut stream = dev.start_stream(&stream_desc).unwrap();
#[allow(dead_code)]
#[cfg(target_os = "macos")]
fn start_camera_capture(
cameras_amount: Arc<AtomicUsize>,
camera_index: Arc<AtomicUsize>,
stop_camera: Arc<AtomicBool>,
) {
use nokhwa::CallbackCamera;
use nokhwa::nokhwa_initialize;
use nokhwa::pixel_format::RgbFormat;
use nokhwa::query;
use nokhwa::utils::ApiBackend;
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
loop {
// Stop if camera was stopped.
if stop_camera.load(Ordering::Relaxed) {
stop_camera.store(false, Ordering::Relaxed);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
// Get a frame.
let frame = stream.next().expect("Stream is dead").expect("Failed to capture a frame");
let mut out = vec![];
if let Some(buf) = image::ImageBuffer::<image::Rgb<u8>, &[u8]>::from_raw(w, h, &frame) {
image::codecs::jpeg::JpegEncoder::new(&mut out)
.write_image(buf.as_raw(), w, h, image::ExtendedColorType::Rgb8).unwrap();
} else {
out = frame.to_vec();
}
// Save image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((out, 0));
}
}
// Ask permission to open camera.
nokhwa_initialize(|_| {});
thread::spawn(move || {
let cameras = query(ApiBackend::Auto).unwrap();
cameras_amount.store(cameras.len(), Ordering::Relaxed);
let index = camera_index.load(Ordering::Relaxed);
if cameras.is_empty() || index >= cameras.len() {
return;
}
// Start camera.
let camera_index = CameraIndex::Index(camera_index.load(Ordering::Relaxed) as u32);
let camera_callback = CallbackCamera::new(
camera_index,
RequestedFormat::new::<RgbFormat>(RequestedFormatType::AbsoluteHighestFrameRate),
|_| {},
);
if let Ok(mut cb) = camera_callback {
if cb.open_stream().is_ok() {
loop {
// Stop if camera was stopped.
if stop_camera.load(Ordering::Relaxed) {
stop_camera.store(false, Ordering::Relaxed);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
// Get image from camera.
if let Ok(frame) = cb.poll_frame() {
let image = frame.decode_image::<RgbFormat>().unwrap();
let mut bytes: Vec<u8> = Vec::new();
let format = image::ImageFormat::Jpeg;
// Convert image to Jpeg format.
image
.write_to(&mut std::io::Cursor::new(&mut bytes), format)
.unwrap();
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((bytes, 0));
} else {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
}
}
}
});
}
}
impl PlatformCallbacks for Desktop {
fn set_context(&mut self, ctx: &egui::Context) {
let mut w_ctx = self.ctx.write();
*w_ctx = Some(ctx.clone());
}
fn exit(&self) {
let r_ctx = self.ctx.read();
if r_ctx.is_some() {
let ctx = r_ctx.as_ref().unwrap();
ctx.send_viewport_cmd(ViewportCommand::Close);
}
}
fn copy_string_to_buffer(&self, data: String) {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.set_text(data).unwrap();
}
fn get_string_from_buffer(&self) -> String {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.get_text().unwrap_or("".to_string())
}
fn start_camera(&self) {
// Clear image.
{
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
}
// Setup stop camera flag.
let stop_camera = self.stop_camera.clone();
stop_camera.store(false, Ordering::Relaxed);
Self::start_camera_capture(
self.cameras_amount.clone(),
self.camera_index.clone(),
stop_camera,
);
}
fn stop_camera(&self) {
// Stop camera.
self.stop_camera.store(true, Ordering::Relaxed);
}
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
let r_image = LAST_CAMERA_IMAGE.read();
if r_image.is_some() {
return r_image.clone();
}
None
}
fn can_switch_camera(&self) -> bool {
let amount = self.cameras_amount.load(Ordering::Relaxed);
amount > 1
}
fn switch_camera(&self) {
self.stop_camera();
let index = self.camera_index.load(Ordering::Relaxed);
let amount = self.cameras_amount.load(Ordering::Relaxed);
if index == amount - 1 {
self.camera_index.store(0, Ordering::Relaxed);
} else {
self.camera_index.store(index + 1, Ordering::Relaxed);
}
self.start_camera();
}
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
let folder = FileDialog::new()
.set_title(t!("share"))
.set_directory(dirs::home_dir().unwrap())
.set_file_name(name.clone())
.save_file();
if let Some(folder) = folder {
let mut image = File::create(folder)?;
image.write_all(data.as_slice())?;
image.sync_all()?;
}
Ok(())
}
fn pick_file(&self) -> Option<String> {
let file = FileDialog::new()
.set_title(t!("choose_file"))
.set_directory(dirs::home_dir().unwrap())
.pick_file();
if let Some(file) = file {
return Some(file.to_str().unwrap_or_default().to_string());
}
None
}
fn pick_image_file(&self) -> Option<String> {
let file = FileDialog::new()
.set_title(t!("choose_file"))
.add_filter("Images", &["png", "jpg", "jpeg", "webp"])
.set_directory(dirs::home_dir().unwrap())
.pick_file();
file.and_then(|f| f.to_str().map(|s| s.to_string()))
}
fn pick_folder(&self) -> Option<String> {
let file = FileDialog::new()
.set_title(t!("choose_folder"))
.set_directory(dirs::home_dir().unwrap())
.pick_folder();
if let Some(file) = file {
return Some(file.to_str().unwrap_or_default().to_string());
}
None
}
fn picked_file(&self) -> Option<String> {
None
}
fn request_user_attention(&self) {
let r_ctx = self.ctx.read();
if r_ctx.is_some() {
let ctx = r_ctx.as_ref().unwrap();
// Request attention on taskbar.
ctx.send_viewport_cmd(ViewportCommand::RequestUserAttention(
UserAttentionType::Informational,
));
// Un-minimize window.
if ctx.input(|i| i.viewport().minimized.unwrap_or(false)) {
ctx.send_viewport_cmd(ViewportCommand::Minimized(false));
}
// Focus to window.
if !ctx.input(|i| i.viewport().focused.unwrap_or(false)) {
ctx.send_viewport_cmd(ViewportCommand::WindowLevel(WindowLevel::AlwaysOnTop));
ctx.send_viewport_cmd(ViewportCommand::Focus);
}
ctx.request_repaint();
}
self.attention_required.store(true, Ordering::Relaxed);
}
fn user_attention_required(&self) -> bool {
self.attention_required.load(Ordering::Relaxed)
}
fn clear_user_attention(&self) {
let r_ctx = self.ctx.read();
if r_ctx.is_some() {
let ctx = r_ctx.as_ref().unwrap();
ctx.send_viewport_cmd(ViewportCommand::RequestUserAttention(
UserAttentionType::Reset,
));
ctx.send_viewport_cmd(ViewportCommand::WindowLevel(WindowLevel::Normal));
}
self.attention_required.store(false, Ordering::Relaxed);
}
}
lazy_static! {
/// Last captured image from started camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
/// Last captured image from started camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
}
+47 -13
View File
@@ -22,16 +22,50 @@ pub mod platform;
pub mod platform;
pub trait PlatformCallbacks {
fn show_keyboard(&self);
fn hide_keyboard(&self);
fn copy_string_to_buffer(&self, data: String);
fn get_string_from_buffer(&self) -> String;
fn start_camera(&self);
fn stop_camera(&self);
fn camera_image(&self) -> Option<(Vec<u8>, u32)>;
fn can_switch_camera(&self) -> bool;
fn switch_camera(&self);
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
fn pick_file(&self) -> Option<String>;
fn picked_file(&self) -> Option<String>;
}
fn set_context(&mut self, ctx: &egui::Context);
fn exit(&self);
fn copy_string_to_buffer(&self, data: String);
fn get_string_from_buffer(&self) -> String;
fn start_camera(&self);
fn stop_camera(&self);
fn camera_image(&self) -> Option<(Vec<u8>, u32)>;
fn can_switch_camera(&self) -> bool;
fn switch_camera(&self);
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
/// 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).
fn share_text(&self, text: String) {
self.copy_string_to_buffer(text);
}
fn pick_file(&self) -> Option<String>;
/// Native picker filtered to picture files; defaults to the plain picker
/// on platforms without filter support (magic-byte sniffing protects).
fn pick_image_file(&self) -> Option<String> {
self.pick_file()
}
fn pick_folder(&self) -> Option<String>;
fn picked_file(&self) -> Option<String>;
fn request_user_attention(&self);
fn user_attention_required(&self) -> bool;
fn clear_user_attention(&self);
/// Set the status-bar icon color to contrast the current theme. `white` =
/// light icons (for a dark background). No-op off Android.
fn set_status_bar_white_icons(&self, _white: bool) {}
/// Play a short "error" haptic (e.g. a rejected over-balance payment).
/// No-op off Android.
fn vibrate_error(&self) {}
/// Play a tiny "tick" haptic confirming a successful copy. No-op off Android.
fn vibrate_copy(&self) {}
}
+357
View File
@@ -0,0 +1,357 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Goblin design tokens: three themes (light/dark/yellow) and density scales,
//! taken verbatim from the Goblin design handoff.
use std::cell::Cell;
use egui::Color32;
use crate::AppConfig;
/// Available color themes.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ThemeKind {
Light,
Dark,
Yellow,
}
impl ThemeKind {
pub fn id(&self) -> &'static str {
match self {
ThemeKind::Light => "light",
ThemeKind::Dark => "dark",
ThemeKind::Yellow => "yellow",
}
}
pub fn from_id(id: &str) -> Option<ThemeKind> {
match id {
"light" => Some(ThemeKind::Light),
"dark" => Some(ThemeKind::Dark),
"yellow" => Some(ThemeKind::Yellow),
_ => None,
}
}
}
/// Color tokens for a theme.
pub struct ThemeTokens {
pub bg: Color32,
pub surface: Color32,
pub surface2: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_mute: Color32,
/// Text on surface/surface2 fills. Matches `text` in light/dark, but the
/// yellow theme has dark surfaces on a bright bg, so on-surface text must
/// be light there while `text` stays dark for the bg.
pub surface_text: Color32,
pub surface_text_dim: Color32,
pub surface_text_mute: Color32,
pub line: Color32,
pub accent: Color32,
pub accent_dark: Color32,
pub accent_ink: Color32,
pub pos: Color32,
pub neg: Color32,
pub chip: Color32,
pub hover: Color32,
/// Avatar background palette (initial ink picked by luminance).
pub avatar_pairs: [(Color32, Color32); 8],
/// Whether egui widgets should use the dark base style.
pub dark_base: bool,
}
/// Avatar (background, ink) pairs shared by all themes — bright pastels
/// carry dark ink, saturated darks carry light ink.
const AVATAR_PAIRS: [(Color32, Color32); 8] = [
(
Color32::from_rgb(0xFF, 0xD6, 0x0A),
Color32::from_rgb(0x0E, 0x0E, 0x0C),
), // accent yellow / ink
(
Color32::from_rgb(0xFF, 0x8E, 0x3C),
Color32::from_rgb(0x26, 0x10, 0x02),
), // orange / deep brown
(
Color32::from_rgb(0x5B, 0xD2, 0x7A),
Color32::from_rgb(0x0E, 0x0E, 0x0C),
), // light green / black
(
Color32::from_rgb(0x7B, 0xA7, 0xFF),
Color32::from_rgb(0x0B, 0x14, 0x33),
), // periwinkle / navy ink
(
Color32::from_rgb(0x6B, 0x4F, 0xC8),
Color32::from_rgb(0xF4, 0xF0, 0xFF),
), // purple / light text
(
Color32::from_rgb(0xE1, 0x74, 0xD0),
Color32::from_rgb(0x32, 0x07, 0x2B),
), // pink / dark plum
(
Color32::from_rgb(0x1F, 0x7A, 0x5C),
Color32::from_rgb(0xE7, 0xFF, 0xF4),
), // deep teal / light mint
(
Color32::from_rgb(0xA0, 0xE6, 0x6E),
Color32::from_rgb(0x14, 0x22, 0x0A),
), // lime / dark moss
];
pub const LIGHT: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface: Color32::from_rgb(0xFF, 0xFF, 0xFF),
surface2: Color32::from_rgb(0xF2, 0xF1, 0xEC),
text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63),
text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B),
surface_text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface_text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63),
surface_text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B),
// rgba(14,14,12,0.08) premultiplied.
line: Color32::from_rgba_premultiplied(1, 1, 1, 20),
accent: Color32::from_rgb(0xFF, 0xD6, 0x0A),
accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00),
accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C),
pos: Color32::from_rgb(0x0E, 0x7C, 0x3A),
neg: Color32::from_rgb(0xB0, 0x48, 0x1E),
chip: Color32::from_rgb(0xF2, 0xF1, 0xEC),
hover: Color32::from_rgb(0xE9, 0xE7, 0xE0),
avatar_pairs: AVATAR_PAIRS,
dark_base: false,
};
pub const DARK: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface: Color32::from_rgb(0x1A, 0x1A, 0x17),
surface2: Color32::from_rgb(0x24, 0x24, 0x20),
text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
// rgba(255,255,255,0.08) premultiplied.
line: Color32::from_rgba_premultiplied(20, 20, 20, 20),
accent: Color32::from_rgb(0xFF, 0xD6, 0x0A),
accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00),
accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C),
pos: Color32::from_rgb(0x5B, 0xD2, 0x7A),
neg: Color32::from_rgb(0xFF, 0x8B, 0x5E),
chip: Color32::from_rgb(0x24, 0x24, 0x20),
hover: Color32::from_rgb(0x2E, 0x2E, 0x29),
avatar_pairs: AVATAR_PAIRS,
dark_base: true,
};
pub const YELLOW: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0xFF, 0xD6, 0x0A),
surface: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface2: Color32::from_rgb(0x1A, 0x1A, 0x17),
text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
text_dim: Color32::from_rgb(0x3A, 0x3A, 0x36),
// Muted on-bg tier darkened for the bright yellow bg: #6B6A63 was only
// 3.85:1 (sub-WCAG-AA); #55534A is 5.5:1 and still the faintest tier.
text_mute: Color32::from_rgb(0x55, 0x53, 0x4A),
surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
// rgba(14,14,12,0.18) premultiplied.
line: Color32::from_rgba_premultiplied(2, 2, 2, 46),
accent: Color32::from_rgb(0x0E, 0x0E, 0x0C),
accent_dark: Color32::from_rgb(0x24, 0x24, 0x20),
accent_ink: Color32::from_rgb(0xFF, 0xD6, 0x0A),
pos: Color32::from_rgb(0x0E, 0x7C, 0x3A),
neg: Color32::from_rgb(0x9E, 0x2E, 0x0E),
chip: Color32::from_rgba_premultiplied(2, 2, 2, 20),
hover: Color32::from_rgb(0xEF, 0xC8, 0x00),
avatar_pairs: AVATAR_PAIRS,
dark_base: false,
};
thread_local! {
/// Per-frame theme override (see [`scoped`]). egui renders on one thread, so
/// a thread-local Cell scopes a different theme to a single surface without
/// touching the persisted app config.
static OVERRIDE: Cell<Option<ThemeKind>> = const { Cell::new(None) };
}
/// RAII guard that forces [`kind`]/[`tokens`] to a specific theme for its
/// lifetime, restoring the previous value on drop (panic-safe). Used to paint
/// one surface — the Pay tab — in the yellow theme regardless of the user's
/// chosen theme, à la 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>);
impl Drop for ScopedTheme {
fn drop(&mut self) {
OVERRIDE.with(|c| c.set(self.0.take()));
}
}
/// Override the active theme until the returned guard drops.
pub fn scoped(kind: ThemeKind) -> ScopedTheme {
ScopedTheme(OVERRIDE.with(|c| c.replace(Some(kind))))
}
/// Current theme kind: a scoped override if one is active, else app config
/// (dark is the product default).
pub fn kind() -> ThemeKind {
OVERRIDE.with(|c| c.get()).unwrap_or_else(AppConfig::theme)
}
/// Current theme tokens.
pub fn tokens() -> &'static ThemeTokens {
match kind() {
ThemeKind::Light => &LIGHT,
ThemeKind::Dark => &DARK,
ThemeKind::Yellow => &YELLOW,
}
}
/// Set each frame by the Pay surface (which paints a bright yellow top under a
/// possibly-dark global theme), so the status bar can pick readable icons for it.
static YELLOW_SURFACE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Flag whether the bright Pay/yellow surface is currently on screen.
pub fn set_status_surface_yellow(yellow: bool) {
YELLOW_SURFACE.store(yellow, std::sync::atomic::Ordering::Relaxed);
}
/// Whether the status bar should use light (white) icons: true on the dark
/// theme (dark top), false on the light/yellow themes (bright top). The bright
/// Pay surface forces dark icons even when the global theme is dark.
pub fn status_bar_white_icons() -> bool {
if YELLOW_SURFACE.load(std::sync::atomic::Ordering::Relaxed) {
return false;
}
tokens().dark_base
}
/// Density scales from the design handoff.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DensityKind {
Compact,
Regular,
Comfy,
}
impl DensityKind {
pub fn id(&self) -> &'static str {
match self {
DensityKind::Compact => "compact",
DensityKind::Regular => "regular",
DensityKind::Comfy => "comfy",
}
}
pub fn from_id(id: &str) -> Option<DensityKind> {
match id {
"compact" => Some(DensityKind::Compact),
"regular" => Some(DensityKind::Regular),
"comfy" => Some(DensityKind::Comfy),
_ => None,
}
}
}
/// Spacing tokens for a density.
#[derive(Clone, Copy)]
pub struct DensityTokens {
pub pad: f32,
pub gap: f32,
pub radius: f32,
pub row: f32,
}
pub const COMPACT: DensityTokens = DensityTokens {
pad: 12.0,
gap: 10.0,
radius: 10.0,
row: 56.0,
};
pub const REGULAR: DensityTokens = DensityTokens {
pad: 16.0,
gap: 14.0,
radius: 16.0,
row: 64.0,
};
pub const COMFY: DensityTokens = DensityTokens {
pad: 20.0,
gap: 18.0,
radius: 22.0,
row: 72.0,
};
/// Current density tokens from app config (comfy is the product default).
pub fn density() -> DensityTokens {
match AppConfig::density() {
DensityKind::Compact => COMPACT,
DensityKind::Regular => REGULAR,
DensityKind::Comfy => COMFY,
}
}
/// Font family helpers for the Geist weight stack registered in `setup_fonts`.
pub mod fonts {
use egui::{FontFamily, FontId};
pub fn regular() -> FontFamily {
FontFamily::Proportional
}
pub fn medium() -> FontFamily {
FontFamily::Name("geist-medium".into())
}
pub fn semibold() -> FontFamily {
FontFamily::Name("geist-semibold".into())
}
pub fn bold() -> FontFamily {
FontFamily::Name("geist-bold".into())
}
pub fn mono() -> FontFamily {
FontFamily::Monospace
}
pub fn mono_semibold() -> FontFamily {
FontFamily::Name("geist-mono-sb".into())
}
/// Uppercase kicker label size (11px in the design).
pub fn kicker() -> FontId {
FontId::new(11.0, semibold())
}
}
/// Pick a readable ink (black or white) for the given background by luminance.
pub fn ink_for(bg: Color32) -> Color32 {
let lum = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
if lum > 140.0 {
Color32::from_rgb(0x0E, 0x0E, 0x0C)
} else {
Color32::from_rgb(0xFA, 0xFA, 0xF7)
}
}
/// Number of avatar color pairs (hue derivation modulus).
pub fn avatar_pairs_len() -> usize {
tokens().avatar_pairs.len()
}
+478 -393
View File
@@ -12,432 +12,517 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use parking_lot::RwLock;
use std::thread;
use eframe::emath::Align;
use egui::load::SizedTexture;
use egui::{Layout, Pos2, Rect, RichText, TextureOptions, Widget};
use image::{DynamicImage, EncodableLayout, ImageFormat};
use egui::{Pos2, Rect, RichText, TextureOptions, UiBuilder, Widget};
use grin_keychain::mnemonic::WORDS;
use grin_util::ZeroingString;
use grin_wallet_libwallet::SlatepackAddress;
use grin_keychain::mnemonic::WORDS;
use image::{DynamicImage, EncodableLayout};
use parking_lot::RwLock;
use std::sync::Arc;
use std::thread;
use crate::gui::Colors;
use crate::gui::icons::CAMERA_ROTATE;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::{QrScanResult, QrScanState};
use crate::gui::views::View;
use crate::wallet::types::PhraseSize;
use crate::gui::views::types::{QrScanResult, QrScanState};
use crate::wallet::WalletUtils;
use crate::wallet::types::PhraseSize;
/// Camera QR code scanner.
pub struct CameraContent {
/// QR code scanning progress and result.
qr_scan_state: Arc<RwLock<QrScanState>>,
/// Uniform Resources URIs collected from QR code scanning.
ur_data: Arc<RwLock<Option<(Vec<String>, usize)>>>,
/// QR code scanning progress and result.
qr_scan_state: Arc<RwLock<QrScanState>>,
/// Uniform Resources URIs collected from QR code scanning.
ur_data: Arc<RwLock<Option<(Vec<String>, usize)>>>,
/// When waiting for the first frame started, to surface missing cameras.
wait_start: std::time::Instant,
}
impl Default for CameraContent {
fn default() -> Self {
Self {
qr_scan_state: Arc::new(RwLock::new(QrScanState::default())),
ur_data: Arc::new(RwLock::new(None)),
}
}
fn default() -> Self {
Self {
qr_scan_state: Arc::new(RwLock::new(QrScanState::default())),
ur_data: Arc::new(RwLock::new(None)),
wait_start: std::time::Instant::now(),
}
}
}
impl CameraContent {
/// Draw camera content.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
// Draw last image from camera or loader.
if let Some(img_data) = cb.camera_image() {
// Load image to draw.
if let Ok(mut img) =
image::load_from_memory_with_format(&*img_data.0, ImageFormat::Jpeg) {
// Process image to find QR code.
self.scan_qr(&img);
// Setup image rotation.
img = match img_data.1 {
90 => img.rotate90(),
180 => img.rotate180(),
270 => img.rotate270(),
_ => img
};
// Convert to ColorImage to add at content.
let color_img = match &img {
DynamicImage::ImageRgb8(image) => {
egui::ColorImage::from_rgb(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
)
},
other => {
let image = other.to_rgba8();
egui::ColorImage::from_rgba_unmultiplied(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
)
},
};
// Create image texture.
let texture = ui.ctx().load_texture("camera_image",
color_img.clone(),
TextureOptions::default());
let img_size = egui::emath::vec2(color_img.width() as f32,
color_img.height() as f32);
let sized_img = SizedTexture::new(texture.id(), img_size);
// Add image to content.
ui.vertical_centered(|ui| {
egui::Image::from_texture(sized_img)
// Setup to crop image at square.
.uv(Rect::from([
Pos2::new(1.0 - (img_size.y / img_size.x), 0.0),
Pos2::new(1.0, 1.0)
]))
.max_height(ui.available_width())
.maintain_aspect_ratio(false)
.shrink_to_fit()
.ui(ui);
});
/// Draw camera content.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let rect = if let Some(img_data) = cb.camera_image() {
if let Ok(img) = image::load_from_memory(&*img_data.0) {
// Process image to find QR code.
self.scan_qr(&img);
// Show UR scan progress.
let show_ur_progress = {
self.ur_data.clone().read().is_some()
};
let ur_progress = self.ur_progress();
if show_ur_progress && ur_progress != 0 {
ui.add_space(-52.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(format!("{}%", ur_progress))
.size(16.0)
.color(Colors::yellow()));
});
}
// Draw image.
let img_rect = self.image_ui(ui, img, img_data.1);
// Show button to switch cameras.
if cb.can_switch_camera() {
ui.add_space(-52.0);
let mut size = ui.available_size();
size.y = 48.0;
ui.allocate_ui_with_layout(size, Layout::right_to_left(Align::Max), |ui| {
ui.add_space(4.0);
View::button(ui, CAMERA_ROTATE.to_string(), Colors::white_or_black(false), || {
cb.switch_camera();
});
});
}
} else {
self.loading_content_ui(ui);
}
} else {
self.loading_content_ui(ui);
}
img_rect
} else {
self.loading_ui(ui)
}
} else {
self.loading_ui(ui)
};
// Request redraw.
ui.ctx().request_repaint();
}
// Show UR scan progress.
self.ur_progress_ui(ui, &rect);
/// Draw camera loading progress content.
fn loading_content_ui(&self, ui: &mut egui::Ui) {
let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0;
ui.vertical_centered(|ui| {
ui.add_space(space);
View::big_loading_spinner(ui);
ui.add_space(space);
});
}
// Show button to switch cameras.
if cb.can_switch_camera() {
let r = {
let mut r = rect.clone();
r.min.y = r.max.y - 52.0;
r.min.x = r.max.x - 52.0;
r
};
ui.scope_builder(UiBuilder::new().max_rect(r), |ui| {
let rotate_img = CAMERA_ROTATE.to_string();
View::button(ui, rotate_img, Colors::white_or_black(false), || {
cb.switch_camera();
});
});
}
ui.add_space(6.0);
ui.ctx().request_repaint();
}
/// Check if image is processing to find QR code.
fn image_processing(&self) -> bool {
let r_scan = self.qr_scan_state.read();
r_scan.image_processing
}
/// Draw camera image.
fn image_ui(&mut self, ui: &mut egui::Ui, mut img: DynamicImage, rotation: u32) -> Rect {
// Setup image rotation.
img = match rotation {
90 => img.rotate90(),
180 => img.rotate180(),
270 => img.rotate270(),
_ => img,
};
if View::is_desktop() {
img = img.fliph();
}
// Convert to ColorImage.
let color_img = match &img {
DynamicImage::ImageRgb8(image) => egui::ColorImage::from_rgb(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
),
other => {
let image = other.to_rgba8();
egui::ColorImage::from_rgba_unmultiplied(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
)
}
};
// Create image texture.
let texture =
ui.ctx()
.load_texture("camera_image", color_img.clone(), TextureOptions::default());
let img_size = egui::emath::vec2(color_img.width() as f32, color_img.height() as f32);
let sized_img = SizedTexture::new(texture.id(), img_size);
egui::Image::from_texture(sized_img)
// Setup to crop image at square.
.uv(Rect::from([
if img_size.y > img_size.x {
Pos2::new(0.0, 1.0 - (img_size.x / img_size.y))
} else {
Pos2::new(1.0 - (img_size.y / img_size.x), 0.0)
},
Pos2::new(1.0, 1.0),
]))
.max_height(ui.available_width())
.maintain_aspect_ratio(false)
.shrink_to_fit()
.ui(ui)
.rect
}
/// Get UR scanning progress in percents.
fn ur_progress(&self) -> i32 {
// Setup data.
let r_data = self.ur_data.read();
let (data, total) = r_data.clone().unwrap_or((vec![], 0));
if data.is_empty() {
return 0;
}
// Calculate progress.
let mut complete = 0;
for i in &data {
if !i.is_empty() {
complete += 1;
}
}
(100 * complete / total) as i32
}
/// Draw animated QR code scanning progress.
fn ur_progress_ui(&self, ui: &mut egui::Ui, rect: &Rect) {
let show_ur_progress = { self.ur_data.as_ref().read().is_some() };
if show_ur_progress {
ui.scope_builder(UiBuilder::new().max_rect(rect.clone()), |ui| {
ui.centered_and_justified(|ui| {
ui.label(
RichText::new(format!("{}%", self.ur_progress()))
.size(32.0)
.color(Colors::gold_dark()),
);
});
});
}
}
/// Parse QR code from provided image data.
fn scan_qr(&self, image_data: &DynamicImage) {
// Do not scan when another image is processing.
if self.image_processing() {
return;
}
// Setup scanning flag.
{
let mut w_scan = self.qr_scan_state.write();
w_scan.image_processing = true;
}
/// Draw camera loading progress content, or a missing-camera notice when
/// no frame ever arrives (no device, device busy, or capture failed).
fn loading_ui(&self, ui: &mut egui::Ui) -> Rect {
if self.wait_start.elapsed().as_secs() >= 5 {
let space = ui.available_width() / 3.0;
return ui
.vertical_centered(|ui| {
ui.add_space(space);
ui.label(
RichText::new("No camera found")
.size(17.0)
.color(Colors::inactive_text()),
);
ui.add_space(space);
})
.response
.rect;
}
let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0;
ui.vertical_centered(|ui| {
ui.add_space(space);
View::big_loading_spinner(ui);
ui.add_space(space);
})
.response
.rect
}
let image_data = image_data.clone();
let qr_scan_state = self.qr_scan_state.clone();
let ur_data = self.ur_data.clone();
/// Check if image is processing to find QR code.
fn image_processing(&self) -> bool {
let r_scan = self.qr_scan_state.read();
r_scan.image_processing
}
let on_scan = async move {
// Prepare image data.
let img = image_data.to_luma8();
let mut img: rqrr::PreparedImage<image::GrayImage>
= rqrr::PreparedImage::prepare(img);
// Scan and save results.
let grids = img.detect_grids();
if let Some(g) = grids.get(0) {
let mut qr_data = vec![];
if let Ok(_) = g.decode_to(&mut qr_data) {
// Setup scanned data into text.
let text = String::from_utf8(qr_data.clone()).unwrap_or("".to_string());
// Setup current text.
let cur_text = {
let r_scan = qr_scan_state.read();
let text = if let Some(res) = r_scan.qr_scan_result.clone() {
res.text()
} else {
"".to_string()
};
text
};
// Parse non-empty data if parsed text is different from saved.
if !qr_data.is_empty() && (cur_text.is_empty() || text != cur_text) {
let res = Self::parse_qr_code(qr_data);
match res {
QrScanResult::URPart(uri, index, total) => {
// Setup current UR data.
let mut cur_data = {
let r_data = ur_data.read();
let mut cur_data = vec!["".to_string(); total];
if let Some((d, _)) = r_data.clone() {
cur_data = d;
}
cur_data
};
if !cur_data.contains(&uri) {
// Save part of UR data.
{
cur_data.insert(index, uri);
let mut w_data = ur_data.write();
*w_data = Some((cur_data.clone(), total));
}
// Setup UR decoder.
let mut decoder = ur::Decoder::default();
for m in cur_data {
if !m.is_empty() {
if let Ok(_) = decoder.receive(m.as_str()) {
continue;
} else {
break;
}
}
}
// Check if UR data is complete.
if decoder.complete() {
if let Ok(data) = decoder.message() {
// Parse complete data.
let res = Self::parse_qr_code(data.unwrap_or(vec![]));
// Clean UR data.
let mut w_data = ur_data.write();
*w_data = None;
// Save scan result.
let mut w_scan = qr_scan_state.write();
w_scan.qr_scan_result = Some(res);
return;
}
}
}
}
_ => {
// Clean UR data.
let mut w_data = ur_data.write();
*w_data = None;
// Save scan result.
let mut w_scan = qr_scan_state.write();
w_scan.qr_scan_result = Some(res);
return;
}
}
}
}
}
// Reset scanning flag to process again.
{
let mut w_scan = qr_scan_state.write();
w_scan.image_processing = false;
}
};
/// Get UR scanning progress in percents.
fn ur_progress(&self) -> i32 {
// Setup data.
let r_data = self.ur_data.read();
let (data, total) = r_data.clone().unwrap_or((vec![], 0));
if data.is_empty() {
return 0;
}
// Calculate progress.
let mut complete = 0;
for i in &data {
if !i.is_empty() {
complete += 1;
}
}
(100 * complete / total) as i32
}
// Launch scanner at separate thread.
thread::spawn(move || {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(on_scan);
});
}
/// Parse QR code from provided image data.
fn scan_qr(&self, image_data: &DynamicImage) {
// Do not scan when another image is processing.
if self.image_processing() {
return;
}
// Setup scanning flag.
{
let mut w_scan = self.qr_scan_state.write();
w_scan.image_processing = true;
}
/// Parse QR code scan result.
fn parse_qr_code(data: Vec<u8>) -> QrScanResult {
// Check if string starts with Grin address prefix.
let text_string = String::from_utf8(data.clone()).unwrap_or("".to_string());
let text = text_string.trim();
if text.starts_with("tgrin") || text.starts_with("grin") {
if SlatepackAddress::try_from(text).is_ok() {
return QrScanResult::Address(ZeroingString::from(text));
}
}
let image_data = image_data.clone();
let qr_scan_state = self.qr_scan_state.clone();
let ur_data = self.ur_data.clone();
// Check if string contains Slatepack message prefix and postfix.
if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") {
return QrScanResult::Slatepack(ZeroingString::from(text));
}
let on_scan = async move {
// Prepare image data.
let img = image_data.to_luma8();
let mut img: rqrr::PreparedImage<image::GrayImage> = rqrr::PreparedImage::prepare(img);
// Scan and save results.
let grids = img.detect_grids();
if let Some(g) = grids.get(0) {
let mut qr_data = vec![];
if let Ok(_) = g.decode_to(&mut qr_data) {
// Setup scanned data into text.
let text = String::from_utf8(qr_data.clone()).unwrap_or("".to_string());
// Setup current text.
let cur_text = {
let r_scan = qr_scan_state.read();
let text = if let Some(res) = r_scan.qr_scan_result.clone() {
res.text()
} else {
"".to_string()
};
text
};
// Parse non-empty data if parsed text is different from saved.
if !qr_data.is_empty() && (cur_text.is_empty() || text != cur_text) {
let res = Self::parse_qr_code(qr_data);
match res {
QrScanResult::URPart(uri, index, total) => {
// Setup current UR data.
let mut cur_data = {
let r_data = ur_data.read();
let mut cur_data = vec!["".to_string(); total];
if let Some((d, _)) = r_data.clone() {
cur_data = d;
}
cur_data
};
if !cur_data.contains(&uri) {
// Save part of UR data.
{
cur_data.insert(index, uri);
let mut w_data = ur_data.write();
*w_data = Some((cur_data.clone(), total));
}
// Setup UR decoder.
let mut decoder = ur::Decoder::default();
for m in cur_data {
if !m.is_empty() {
if let Ok(_) = decoder.receive(m.as_str()) {
continue;
} else {
break;
}
}
}
// Check if UR data is complete.
if decoder.complete() {
if let Ok(data) = decoder.message() {
// Parse complete data.
let res = Self::parse_qr_code(data.unwrap_or(vec![]));
// Clean UR data.
let mut w_data = ur_data.write();
*w_data = None;
// Save scan result.
let mut w_scan = qr_scan_state.write();
w_scan.qr_scan_result = Some(res);
return;
}
}
}
}
_ => {
// Clean UR data.
let mut w_data = ur_data.write();
*w_data = None;
// Save scan result.
let mut w_scan = qr_scan_state.write();
w_scan.qr_scan_result = Some(res);
return;
}
}
}
}
}
// Reset scanning flag to process again.
{
let mut w_scan = qr_scan_state.write();
w_scan.image_processing = false;
}
};
// Check Uniform Resource data.
// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md
if text.starts_with("ur:bytes/") {
let split = text.split("/").collect::<Vec<_>>();
if let Some(index_total) = split.get(1) {
if let Some((index, total)) = index_total.split_once("-") {
let index = index.parse::<usize>();
let total = total.parse::<usize>();
if index.is_ok() && total.is_ok() {
let index = index.unwrap() - 1;
let total = total.unwrap();
return QrScanResult::URPart(text_string, index, total);
}
}
}
}
// Launch scanner at separate thread.
thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(on_scan);
});
}
// Check Compact SeedQR format.
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#compactseedqr-specification
if data.len() <= 32 && 16 <= data.len() && data.len() % 4 == 0 {
// Setup words amount.
let total_bits = data.len() * 8;
let checksum_bits = total_bits / 32;
let total_words = (total_bits + checksum_bits) / 11;
// Setup entropy.
let mut entropy = data.clone();
WalletUtils::setup_checksum(&mut entropy);
// Setup bits.
let mut bits = vec![false; entropy.len() * 8];
for i in 0..entropy.len() {
for j in 0..8 {
bits[(i * 8) + j] = (entropy[i] & (1 << (7 - j))) != 0;
}
}
// Extract word index.
let extract_index = |i: usize| -> usize {
let mut index = 0;
for j in 0..11 {
index = index << 1;
if bits[(i * 11) + j] {
index += 1;
}
}
return index;
};
// Setup words.
let mut words = "".to_string();
for n in 0..total_words {
// Setup word index.
let index = extract_index(n);
// Setup word.
let empty_word = "".to_string();
let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone();
if word.is_empty() {
words = empty_word;
break;
}
words = if words.is_empty() {
format!("{}", word)
} else {
format!("{} {}", words, word)
};
}
if !words.is_empty() {
return QrScanResult::SeedQR(ZeroingString::from(words));
}
}
/// Parse QR code scan result.
fn parse_qr_code(data: Vec<u8>) -> QrScanResult {
// Check if string starts with Grin address prefix.
let text_string = String::from_utf8(data.clone()).unwrap_or("".to_string());
let text = text_string.trim();
if text.starts_with("tgrin") || text.starts_with("grin") {
if SlatepackAddress::try_from(text).is_ok() {
return QrScanResult::Address(ZeroingString::from(text));
}
}
// Check Standard SeedQR format.
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#standard-seedqr-specification
let only_numbers = || {
for c in text.chars() {
if !c.is_numeric() {
return false;
}
}
true
};
if !text.is_empty() && data.len() <= 96 && data.len() % 4 == 0 && only_numbers() {
if let Some(_) = PhraseSize::type_for_value(text.len() / 4) {
let chars: Vec<char> = text.trim().chars().collect();
let split = &chars.chunks(4)
.map(|chunk| chunk.iter().collect::<String>()
.trim()
.trim_start_matches("0")
.to_string()
)
.collect::<Vec<_>>();
let mut words = "".to_string();
for i in split {
let index = if i.is_empty() {
0usize
} else {
i.parse::<usize>().unwrap_or(WORDS.len())
};
let empty_word = "".to_string();
let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone();
// Return text result when BIP39 word was not found.
if word.is_empty() {
return QrScanResult::Text(ZeroingString::from(text));
}
words = if words.is_empty() {
format!("{}", word)
} else {
format!("{} {}", words, word)
};
}
return QrScanResult::SeedQR(ZeroingString::from(words));
}
}
// Check if string contains Slatepack message prefix and postfix.
if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") {
return QrScanResult::Slatepack(text.to_string());
}
// Return default text result.
QrScanResult::Text(ZeroingString::from(text))
}
// Check Uniform Resource data.
// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md
if text.starts_with("ur:bytes/") {
let split = text.split("/").collect::<Vec<_>>();
if let Some(index_total) = split.get(1) {
if let Some((index, total)) = index_total.split_once("-") {
let index = index.parse::<usize>();
let total = total.parse::<usize>();
if index.is_ok() && total.is_ok() {
let index = index.unwrap() - 1;
let total = total.unwrap();
return QrScanResult::URPart(text_string, index, total);
}
}
}
}
/// Get QR code scan result.
pub fn qr_scan_result(&self) -> Option<QrScanResult> {
let r_scan = self.qr_scan_state.read();
if r_scan.qr_scan_result.is_some() {
return Some(r_scan.qr_scan_result.clone().unwrap());
}
None
}
// Check Compact SeedQR format.
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#compactseedqr-specification
if data.len() <= 32 && 16 <= data.len() && data.len() % 4 == 0 {
// Setup words amount.
let total_bits = data.len() * 8;
let checksum_bits = total_bits / 32;
let total_words = (total_bits + checksum_bits) / 11;
// Setup entropy.
let mut entropy = data.clone();
WalletUtils::setup_checksum(&mut entropy);
// Setup bits.
let mut bits = vec![false; entropy.len() * 8];
for i in 0..entropy.len() {
for j in 0..8 {
bits[(i * 8) + j] = (entropy[i] & (1 << (7 - j))) != 0;
}
}
// Extract word index.
let extract_index = |i: usize| -> usize {
let mut index = 0;
for j in 0..11 {
index = index << 1;
if bits[(i * 11) + j] {
index += 1;
}
}
return index;
};
// Setup words.
let mut words = "".to_string();
for n in 0..total_words {
// Setup word index.
let index = extract_index(n);
// Setup word.
let empty_word = "".to_string();
let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone();
if word.is_empty() {
words = empty_word;
break;
}
words = if words.is_empty() {
format!("{}", word)
} else {
format!("{} {}", words, word)
};
}
if !words.is_empty() {
return QrScanResult::SeedQR(ZeroingString::from(words));
}
}
/// Reset camera content state to default.
pub fn clear_state(&mut self) {
// Clear QR code scanning state.
let mut w_scan = self.qr_scan_state.write();
*w_scan = QrScanState::default();
// Clear UR data.
let mut w_data = self.ur_data.write();
*w_data = None;
}
}
// Check Standard SeedQR format.
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#standard-seedqr-specification
let only_numbers = || {
for c in text.chars() {
if !c.is_numeric() {
return false;
}
}
true
};
if !text.is_empty() && data.len() <= 96 && data.len() % 4 == 0 && only_numbers() {
if let Some(_) = PhraseSize::type_for_value(text.len() / 4) {
let chars: Vec<char> = text.trim().chars().collect();
let split = &chars
.chunks(4)
.map(|chunk| {
chunk
.iter()
.collect::<String>()
.trim()
.trim_start_matches("0")
.to_string()
})
.collect::<Vec<_>>();
let mut words = "".to_string();
for i in split {
let index = if i.is_empty() {
0usize
} else {
i.parse::<usize>().unwrap_or(WORDS.len())
};
let empty_word = "".to_string();
let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone();
// Return text result when BIP39 word was not found.
if word.is_empty() {
return QrScanResult::Text(ZeroingString::from(text));
}
words = if words.is_empty() {
format!("{}", word)
} else {
format!("{} {}", words, word)
};
}
return QrScanResult::SeedQR(ZeroingString::from(words));
}
}
// Return default text result.
QrScanResult::Text(ZeroingString::from(text))
}
/// Get QR code scan result.
pub fn qr_scan_result(&self) -> Option<QrScanResult> {
let r_scan = self.qr_scan_state.read();
if r_scan.qr_scan_result.is_some() {
return Some(r_scan.qr_scan_result.clone().unwrap());
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Render a QR the way the goblin receive card paints it (dark modules
/// on a white plate with ~5% padding, goblin mark covering the center)
/// and prove the camera scanner pipeline (rqrr) decodes it. Guards both
/// the scan path and the card's scannability by third-party apps.
#[test]
fn goblin_receive_qr_decodes_with_center_mark() {
let uri = "nostr:npub15l60z00nm4ptmnsj9lcp4husnaltytw85eu05dt7ksdmsje0p98su2f0ch";
let qr = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::High).unwrap();
let n = qr.size();
// Mirror widgets::qr_code geometry at its receive-card size.
let size = 220.0f32;
let pad = (size * 0.05).max(8.0);
let dim = (size + pad * 2.0).ceil() as u32;
let cell = size / n as f32;
let mut img = image::GrayImage::from_pixel(dim, dim, image::Luma([255u8]));
let mut fill = |x0: f32, y0: f32, w: f32, h: f32, v: u8| {
for y in y0.max(0.0) as u32..((y0 + h).min(dim as f32) as u32) {
for x in x0.max(0.0) as u32..((x0 + w).min(dim as f32) as u32) {
img.put_pixel(x, y, image::Luma([v]));
}
}
};
for y in 0..n {
for x in 0..n {
if qr.get_module(x, y) {
fill(pad + x as f32 * cell, pad + y as f32 * cell, cell, cell, 14);
}
}
}
// The goblin mark backing square over the center modules.
let backing = size * 0.19;
let c = dim as f32 / 2.0;
fill(c - backing / 2.0, c - backing / 2.0, backing, backing, 255);
let mut prepared = rqrr::PreparedImage::prepare(img);
let grids = prepared.detect_grids();
assert_eq!(grids.len(), 1, "scanner should find exactly one QR");
let mut data = vec![];
grids[0].decode_to(&mut data).expect("QR should decode");
assert_eq!(String::from_utf8(data).unwrap(), uri);
}
/// A scanned nostr URI must come back as plain text (the send flow
/// strips the scheme and resolves the npub), never as another variant.
#[test]
fn nostr_uri_parses_as_text() {
let uri = "nostr:npub15l60z00nm4ptmnsj9lcp4husnaltytw85eu05dt7ksdmsje0p98su2f0ch";
match CameraContent::parse_qr_code(uri.as_bytes().to_vec()) {
QrScanResult::Text(text) => assert_eq!(text.to_string(), uri),
other => panic!("expected Text, got {:?}", other.text()),
}
}
}
+323 -348
View File
@@ -12,387 +12,362 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::atomic::{AtomicBool, Ordering};
use egui::RichText;
use egui::os::OperatingSystem;
use egui::{Align, Layout, RichText};
use lazy_static::lazy_static;
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::gui::Colors;
use crate::gui::icons::FILE_X;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, View};
use crate::gui::views::types::ModalContainer;
use crate::node::Node;
use crate::AppConfig;
use crate::gui::icons::{CHECK, CHECK_FAT};
use crate::gui::views::network::{NetworkContent, NodeSetup};
use crate::gui::views::network::NetworkContent;
use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::wallets::WalletsContent;
use crate::gui::views::{Modal, View};
use crate::node::Node;
use crate::{AppConfig, Settings};
lazy_static! {
/// Global state to check if [`NetworkContent`] panel is open.
static ref NETWORK_PANEL_OPEN: AtomicBool = AtomicBool::new(false);
/// Global state to check if [`NetworkContent`] panel is open.
static ref NETWORK_PANEL_OPEN: AtomicBool = AtomicBool::new(false);
}
/// Contains main ui content, handles side panel state.
pub struct Content {
/// Side panel [`NetworkContent`] content.
network: NetworkContent,
/// Central panel [`WalletsContent`] content.
pub wallets: WalletsContent,
/// Side panel [`NetworkContent`] content.
network: NetworkContent,
/// Check if app exit is allowed on close event of [`eframe::App`] implementation.
pub(crate) exit_allowed: bool,
/// Flag to show exit progress at [`Modal`].
show_exit_progress: bool,
/// Central panel [`WalletsContent`] content.
wallets: WalletsContent,
/// Flag to check it's first draw of content.
first_draw: bool,
/// Check if app exit is allowed on Desktop close event.
pub exit_allowed: bool,
/// Flag to show exit progress at [`Modal`].
show_exit_progress: bool,
/// List of allowed [`Modal`] ids for this [`ModalContainer`].
allowed_modal_ids: Vec<&'static str>
/// Flag to check it's first draw of content.
first_draw: bool,
}
impl Default for Content {
fn default() -> Self {
// Exit from eframe only for non-mobile platforms.
let os = OperatingSystem::from_target_os();
let exit_allowed = os == OperatingSystem::Android || os == OperatingSystem::IOS;
Self {
network: NetworkContent::default(),
wallets: WalletsContent::default(),
exit_allowed,
show_exit_progress: false,
first_draw: true,
allowed_modal_ids: vec![
Self::EXIT_MODAL_ID,
Self::SETTINGS_MODAL,
Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL,
],
}
}
fn default() -> Self {
// Exit from eframe only for non-mobile platforms.
let os = OperatingSystem::from_target_os();
let exit_allowed = os == OperatingSystem::Android || os == OperatingSystem::IOS;
Self {
network: NetworkContent::default(),
wallets: WalletsContent::default(),
exit_allowed,
show_exit_progress: false,
first_draw: true,
}
}
}
impl ModalContainer for Content {
fn modal_ids(&self) -> &Vec<&'static str> {
&self.allowed_modal_ids
}
/// Identifier for integrated node warning [`Modal`] on Android.
const ANDROID_INTEGRATED_NODE_WARNING_MODAL: &'static str = "android_node_warning_modal";
/// Identifier for crash report [`Modal`].
const CRASH_REPORT_MODAL: &'static str = "crash_report_modal";
fn modal_ui(&mut self,
ui: &mut egui::Ui,
modal: &Modal,
_: &dyn PlatformCallbacks) {
match modal.id {
Self::EXIT_MODAL_ID => self.exit_modal_content(ui, modal),
Self::SETTINGS_MODAL => self.settings_modal_ui(ui, modal),
Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL => self.android_warning_modal_ui(ui, modal),
_ => {}
}
}
impl ContentContainer for Content {
fn modal_ids(&self) -> Vec<&'static str> {
vec![
Self::EXIT_CONFIRMATION_MODAL,
ANDROID_INTEGRATED_NODE_WARNING_MODAL,
CRASH_REPORT_MODAL,
]
}
fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
match modal.id {
Self::EXIT_CONFIRMATION_MODAL => self.exit_modal_content(ui, modal, cb),
ANDROID_INTEGRATED_NODE_WARNING_MODAL => self.android_warning_modal_ui(ui),
CRASH_REPORT_MODAL => self.crash_report_modal_ui(ui, cb),
_ => {}
}
}
fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let dual_panel = Self::is_dual_panel_mode(ui.ctx());
let (is_panel_open, mut panel_width) = network_panel_state_width(ui.ctx(), dual_panel);
if self.network.showing_settings() {
panel_width = ui.available_width();
}
// The open-wallet (Goblin) surface is full-bleed: node info lives in
// its sidebar, so the network column stays hidden while it shows.
// Same for first-run onboarding, which owns the whole window.
let wallet_open = self.wallets.showing_wallet() || self.wallets.onboarding_active();
// On the returning-user wallet list the node is demoted to a chip:
// the panel opens only when explicitly toggled, never forced open by
// dual-panel mode (otherwise GRIM's node column dominates the list).
let list_screen = self.wallets.wallet_list_screen();
// The app-settings (cog) screen owns the node now: it lives in the
// cog's own section, and the full panel opens only when explicitly
// requested from there — never auto-docked beside settings, which
// would expose the node twice on a wide screen.
let app_settings = self.wallets.showing_settings();
let show_network = !wallet_open
&& if list_screen || app_settings {
Self::is_network_panel_open()
} else {
is_panel_open
};
// Show network content.
egui::SidePanel::left("network_panel")
.resizable(false)
.exact_width(panel_width)
.frame(egui::Frame {
..Default::default()
})
.show_animated_inside(ui, show_network, |ui| {
self.network.ui(ui, cb);
});
// Show wallets content.
egui::CentralPanel::default()
.frame(egui::Frame {
..Default::default()
})
.show_inside(ui, |ui| {
self.wallets.ui(ui, cb);
});
if self.first_draw {
// Show crash report or integrated node Android warning.
if Settings::crash_check_path().exists() {
Modal::new(CRASH_REPORT_MODAL)
.closeable(false)
.position(ModalPosition::Center)
.title(t!("crash_report"))
.show();
} else if OperatingSystem::from_target_os() == OperatingSystem::Android
&& AppConfig::android_integrated_node_warning_needed()
// 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"))
.show();
}
self.first_draw = false;
}
}
}
impl Content {
/// Identifier for exit confirmation [`Modal`].
pub const EXIT_MODAL_ID: &'static str = "exit_confirmation_modal";
/// Identifier for wallet opening [`Modal`].
pub const SETTINGS_MODAL: &'static str = "settings_modal";
/// Default width of side panel at application UI.
pub const SIDE_PANEL_WIDTH: f32 = 400.0;
/// Desktop window title height.
pub const WINDOW_TITLE_HEIGHT: f32 = 38.0;
/// Margin of window frame at desktop.
pub const WINDOW_FRAME_MARGIN: f32 = 6.0;
/// Identifier for integrated node warning [`Modal`] on Android.
const ANDROID_INTEGRATED_NODE_WARNING_MODAL: &'static str = "android_node_warning_modal";
/// Identifier for exit confirmation [`Modal`].
pub const EXIT_CONFIRMATION_MODAL: &'static str = "exit_confirmation_modal";
/// Default width of side panel at application UI.
pub const SIDE_PANEL_WIDTH: f32 = 400.0;
/// Desktop window title height.
pub const WINDOW_TITLE_HEIGHT: f32 = 38.0;
/// Margin of window frame at desktop.
pub const WINDOW_FRAME_MARGIN: f32 = 6.0;
/// Called to navigate back, return `true` if action was not consumed.
pub fn on_back(&mut self, ctx: &egui::Context, cb: &dyn PlatformCallbacks) -> bool {
if Modal::on_back() {
let dual_panel = Self::is_dual_panel_mode(ctx);
if !dual_panel && Self::is_network_panel_open() {
if self.network.on_back() {
Self::toggle_network_panel();
return false;
}
} else if self.wallets.on_back(cb) {
Self::show_exit_modal();
return false;
}
}
true
}
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
// Draw modal content for current ui container.
self.current_modal_ui(ui, cb);
/// Check if ui can show [`NetworkContent`] and [`WalletsContent`] at same time.
pub fn is_dual_panel_mode(ctx: &egui::Context) -> bool {
let (w, h) = View::window_size(ctx);
// Screen is wide if width is greater than height or just 20% smaller.
let is_wide_screen = w > h || w + (w * 0.2) >= h;
// Dual panel mode is available when window is wide and its width is at least 2 times
// greater than minimal width of the side panel plus display insets from both sides.
let side_insets = View::get_left_inset() + View::get_right_inset();
is_wide_screen && w >= (Self::SIDE_PANEL_WIDTH * 2.0) + side_insets
}
let dual_panel = Self::is_dual_panel_mode(ui);
let (is_panel_open, panel_width) = Self::network_panel_state_width(ui, dual_panel);
/// Toggle [`NetworkContent`] panel state.
pub fn toggle_network_panel() {
let is_open = NETWORK_PANEL_OPEN.load(Ordering::Relaxed);
NETWORK_PANEL_OPEN.store(!is_open, Ordering::Relaxed);
}
// Show network content.
egui::SidePanel::left("network_panel")
.resizable(false)
.exact_width(panel_width)
.frame(egui::Frame {
..Default::default()
})
.show_animated_inside(ui, is_panel_open, |ui| {
self.network.ui(ui, cb);
});
/// Check if [`NetworkContent`] panel is open.
pub fn is_network_panel_open() -> bool {
NETWORK_PANEL_OPEN.load(Ordering::Relaxed)
}
// Show wallets content.
egui::CentralPanel::default()
.frame(egui::Frame {
..Default::default()
})
.show_inside(ui, |ui| {
self.wallets.ui(ui, cb);
});
/// Show exit confirmation [`Modal`].
pub fn show_exit_modal() {
Modal::new(Self::EXIT_CONFIRMATION_MODAL)
.title(t!("confirmation"))
.show();
}
// Show integrated node warning on Android if needed.
if self.first_draw && OperatingSystem::from_target_os() == OperatingSystem::Android &&
AppConfig::android_integrated_node_warning_needed() {
Modal::new(Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL)
.title(t!("network.node"))
.show();
}
/// Draw exit confirmation modal content.
fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
if self.show_exit_progress {
if !Node::is_running() && !Node::data_dir_changing() {
self.exit_allowed = true;
cb.exit();
Modal::close();
}
ui.add_space(16.0);
ui.vertical_centered(|ui| {
View::small_loading_spinner(ui);
ui.add_space(12.0);
let exit_status_text = if Node::data_dir_changing() {
t!("moving_files")
} else {
t!("sync_status.shutdown")
};
ui.label(
RichText::new(exit_status_text)
.size(17.0)
.color(Colors::text(false)),
);
});
ui.add_space(10.0);
} else {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("modal_exit.description"))
.size(17.0)
.color(Colors::text(false)),
);
});
ui.add_space(10.0);
// Setup first draw flag.
if self.first_draw {
self.first_draw = false;
}
}
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
/// Get [`NetworkContent`] panel state and width.
fn network_panel_state_width(ui: &mut egui::Ui, dual_panel: bool) -> (bool, f32) {
let is_panel_open = dual_panel || Self::is_network_panel_open();
let panel_width = if dual_panel {
Self::SIDE_PANEL_WIDTH + View::get_left_inset()
} else {
let is_fullscreen = ui.ctx().input(|i| {
i.viewport().fullscreen.unwrap_or(false)
});
View::window_size(ui).0 - if View::is_desktop() && !is_fullscreen &&
OperatingSystem::from_target_os() != OperatingSystem::Mac {
Self::WINDOW_FRAME_MARGIN * 2.0
} else {
0.0
}
};
(is_panel_open, panel_width)
}
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button_ui(
ui,
t!("modal_exit.exit"),
Colors::white_or_black(false),
|_| {
if !Node::is_running() && !Node::data_dir_changing() {
self.exit_allowed = true;
cb.exit();
Modal::close();
} else {
Node::stop(true);
modal.disable_closing();
Modal::set_title(t!("modal_exit.exit"));
self.show_exit_progress = true;
}
},
);
});
});
ui.add_space(6.0);
}
}
/// Check if ui can show [`NetworkContent`] and [`WalletsContent`] at same time.
pub fn is_dual_panel_mode(ui: &egui::Ui) -> bool {
let (w, h) = View::window_size(ui);
// Screen is wide if width is greater than height or just 20% smaller.
let is_wide_screen = w > h || w + (w * 0.2) >= h;
// Dual panel mode is available when window is wide and its width is at least 2 times
// greater than minimal width of the side panel plus display insets from both sides.
let side_insets = View::get_left_inset() + View::get_right_inset();
is_wide_screen && w >= (Self::SIDE_PANEL_WIDTH * 2.0) + side_insets
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn android_warning_modal_ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("network.android_warning"))
.size(16.0)
.color(Colors::text(false)),
);
});
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
AppConfig::show_android_integrated_node_warning();
Modal::close();
});
});
ui.add_space(6.0);
}
/// Toggle [`NetworkContent`] panel state.
pub fn toggle_network_panel() {
let is_open = NETWORK_PANEL_OPEN.load(Ordering::Relaxed);
NETWORK_PANEL_OPEN.store(!is_open, Ordering::Relaxed);
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn crash_report_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("crash_report_warning"))
.size(16.0)
.color(Colors::text(false)),
);
ui.add_space(6.0);
// Draw button to share log file.
let text = format!("{} {}", FILE_X, t!("share"));
View::colored_text_button(
ui,
text,
Colors::blue(),
Colors::white_or_black(false),
|| {
if let Ok(data) = fs::read_to_string(Settings::log_path()) {
let name = Settings::LOG_FILE_NAME.to_string();
let _ = cb.share_data(name, data.as_bytes().to_vec());
}
Settings::delete_crash_check();
Modal::close();
},
);
});
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Settings::delete_crash_check();
Modal::close();
},
);
});
ui.add_space(6.0);
}
}
/// Check if [`NetworkContent`] panel is open.
pub fn is_network_panel_open() -> bool {
NETWORK_PANEL_OPEN.load(Ordering::Relaxed)
}
/// Show exit confirmation modal.
pub fn show_exit_modal() {
Modal::new(Self::EXIT_MODAL_ID)
.title(t!("modal.confirmation"))
.show();
}
/// Draw exit confirmation modal content.
fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal) {
if self.show_exit_progress {
if !Node::is_running() {
self.exit_allowed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
modal.close();
}
ui.add_space(16.0);
ui.vertical_centered(|ui| {
View::small_loading_spinner(ui);
ui.add_space(12.0);
ui.label(RichText::new(t!("sync_status.shutdown"))
.size(17.0)
.color(Colors::text(false)));
});
ui.add_space(10.0);
} else {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("modal_exit.description"))
.size(17.0)
.color(Colors::text(false)));
});
ui.add_space(10.0);
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
modal.close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button_ui(ui, t!("modal_exit.exit"), Colors::white_or_black(false), |ui| {
if !Node::is_running() {
self.exit_allowed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
modal.close();
} else {
Node::stop(true);
modal.disable_closing();
Modal::set_title(t!("modal_exit.exit"));
self.show_exit_progress = true;
}
});
});
});
ui.add_space(6.0);
}
}
/// Handle Back key event.
pub fn on_back(&mut self) {
if Modal::on_back() {
if self.wallets.on_back() {
Self::show_exit_modal()
}
}
}
/// Draw creating wallet name/password input [`Modal`] content.
pub fn settings_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal) {
ui.add_space(6.0);
// Draw chain type selection.
NodeSetup::chain_type_ui(ui);
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Draw theme selection.
Self::theme_selection_ui(ui);
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(format!("{}:", t!("language")))
.size(16.0)
.color(Colors::gray())
);
});
ui.add_space(8.0);
// Draw available list of languages to select.
let locales = rust_i18n::available_locales!();
for (index, locale) in locales.iter().enumerate() {
Self::language_item_ui(locale, ui, index, locales.len(), modal);
}
ui.add_space(8.0);
// Show button to close modal.
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("close"), Colors::white_or_black(false), || {
modal.close();
});
});
ui.add_space(6.0);
}
/// Draw theme selection content.
fn theme_selection_ui(ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("theme")).size(16.0).color(Colors::gray()));
});
let saved_use_dark = AppConfig::dark_theme().unwrap_or(false);
let mut selected_use_dark = saved_use_dark;
ui.add_space(8.0);
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_use_dark, false, t!("light"));
});
columns[1].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_use_dark, true, t!("dark"));
})
});
ui.add_space(8.0);
if saved_use_dark != selected_use_dark {
AppConfig::set_dark_theme(selected_use_dark);
crate::setup_visuals(ui.ctx());
}
}
/// Draw language selection item content.
fn language_item_ui(locale: &str, ui: &mut egui::Ui, index: usize, len: usize, modal: &Modal) {
// Setup layout size.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(50.0);
// Draw round background.
let bg_rect = rect.clone();
let item_rounding = View::item_rounding(index, len, false);
ui.painter().rect(bg_rect, item_rounding, Colors::fill(), View::item_stroke());
ui.vertical(|ui| {
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| {
// Draw button to select language.
let is_current = if let Some(lang) = AppConfig::locale() {
lang == locale
} else {
rust_i18n::locale() == locale
};
if !is_current {
View::item_button(ui, View::item_rounding(index, len, true), CHECK, None, || {
rust_i18n::set_locale(locale);
AppConfig::save_locale(locale);
modal.close();
});
} else {
ui.add_space(14.0);
ui.label(RichText::new(CHECK_FAT).size(20.0).color(Colors::green()));
ui.add_space(14.0);
}
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| {
ui.add_space(12.0);
ui.vertical(|ui| {
// Draw language name.
ui.add_space(12.0);
let color = if is_current {
Colors::title(false)
} else {
Colors::gray()
};
ui.label(RichText::new(t!("lang_name", locale = locale))
.size(17.0)
.color(color));
ui.add_space(3.0);
});
});
});
});
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn android_warning_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("network.android_warning"))
.size(15.0)
.color(Colors::text(false)));
});
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
AppConfig::show_android_integrated_node_warning();
modal.close();
});
});
ui.add_space(6.0);
}
}
/// Get [`NetworkContent`] panel state and width.
fn network_panel_state_width(ctx: &egui::Context, dual_panel: bool) -> (bool, f32) {
let is_panel_open = dual_panel || Content::is_network_panel_open();
let panel_width = if dual_panel {
Content::SIDE_PANEL_WIDTH + View::get_left_inset()
} else {
let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
View::window_size(ctx).0
- if View::is_desktop()
&& !is_fullscreen
&& OperatingSystem::from_target_os() != OperatingSystem::Mac
{
Content::WINDOW_FRAME_MARGIN * 2.0
} else {
0.0
}
};
(is_panel_open, panel_width)
}

Some files were not shown because too many files have changed in this diff Show More