1
0
forked from GRIN/grim

135 Commits

Author SHA1 Message Date
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
218 changed files with 42573 additions and 26821 deletions
-69
View File
@@ -1,69 +0,0 @@
name: Test build
on:
push:
tags-ignore:
- "*"
branches-ignore:
- master
- ci
jobs:
build:
runs-on: ubuntu
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Check commit
id: check
run: |
git fetch && git checkout master
sha=$(git rev-parse HEAD)
[[ "${{ github.sha }}" == "${sha}" ]] && test=false || test=true
echo "test=${test}" >> "$FORGEJO_OUTPUT"
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-test-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
- name: Build
if: ${{ steps.check.outputs.test == 'true' }}
run: cargo build --release
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: Telegram Notify Channel
if: ${{ steps.check.outputs.test == 'true' && (success() || failure()) }}
uses: actions/telegram-notifier@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_CHANNEL_ID }}
status: ${{ job.status }}
notify_fields: "actor,repository,workflow,branch,commit"
- name: Telegram Notify Group
if: ${{ steps.check.outputs.test == 'true' && (success() || failure()) }}
uses: actions/telegram-notifier@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_GROUP_ID }}
status: ${{ job.status }}
notify_fields: "actor,repository,workflow,branch,commit"
-29
View File
@@ -1,29 +0,0 @@
name: Pull Request
on:
pull_request:
types:
- closed
- opened
jobs:
notify:
runs-on: debian-release
steps:
- name: Telegram Notify Channel
uses: actions/telegram-notifier@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_CHANNEL_ID }}
status: ${{ job.status }}
notify_fields: "actor,repository"
- name: Telegram Notify Group
uses: actions/telegram-notifier@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_GROUP_ID }}
status: ${{ job.status }}
notify_fields: "actor,repository"
-437
View File
@@ -1,437 +0,0 @@
name: Release build
on:
push:
branches:
- master
- ci
tags-ignore:
- "*-dev*"
jobs:
version:
runs-on: ubuntu
outputs:
v: ${{ steps.version.outputs.v }}
exists: ${{ steps.check.outputs.exists }}
last_tag: ${{ steps.check_prev.outputs.last_tag }}
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Get version
id: version
run: |
ver="$(grep -m 1 -Po 'version = "\K[^"]*' Cargo.toml)"
[[ $ver == *"-dev"* ]] && ver=${ver} || ver=${ver}-dev
[[ ${{ forgejo.ref_type }} == 'tag' ]] && app_ver=${{ forgejo.ref_name }} || app_ver=v${ver}
echo "v=${app_ver}" >> "$FORGEJO_OUTPUT"
echo $app_ver
[[ ${{ forgejo.ref_type }} == 'tag' ]] && pre='false' || pre='true'
echo "pre=${pre}" >> "$FORGEJO_OUTPUT"
echo "pre-release: ${pre}"
- name: Check existing release
if: ${{ forgejo.ref_type == 'tag' }}
id: check
run: |
git fetch --tags
dev_sha=$(git rev-parse refs/tags/${{ forgejo.ref_name }}-dev) || :
[[ "$(git show-ref)" == *"${dev_sha}"* ]] && exists='true' || exists='false'
echo "exists=${exists}" >> "$FORGEJO_OUTPUT"
echo ${exists}
mkdir release
- uses: ardocrat/forgejo-release@grim
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
with:
direction: download
token: ${{ secrets.RELEASE_TOKEN }}
tag: "${{ forgejo.ref_name }}-dev"
release-dir: ./release
- name: Rename files
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
working-directory: release
run: for f in *; do mv "$f" "$(echo "$f" | sed s/-dev-/-/)"; done
- uses: ardocrat/forgejo-release@grim
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
with:
direction: upload
token: ${{ secrets.RELEASE_TOKEN }}
tag: ${{ forgejo.ref_name }}
override: false
prerelease: false
release-dir: ./release
release-notes: "Full Changelog: [${{ steps.check_prev.outputs.last_tag }}...${{ steps.version.outputs.v }}](https://code.gri.mw/${{ forgejo.repository }}/compare/${{ steps.check_prev.outputs.last_tag }}...${{ steps.version.outputs.v }})"
- name: Delete dev release
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
uses: actions/delete-release@v1
with:
release_name: "${{ forgejo.ref_name }}-dev"
- name: Check previous release
id: check_prev
run: |
git fetch --tags
last_tag=$(git describe --abbrev=0 --tags $(git rev-list --tags --skip=1 --max-count=1))
echo "last_tag=${last_tag}" >> "$FORGEJO_OUTPUT"
android_libs:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: ubuntu
needs: version
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-android-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
- name: Build libs
run: |
chmod +x scripts/android.sh && ./scripts/android.sh lib ${{ needs.version.outputs.v }}
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: Upload artifacts
run: |
cd android/app/src/main
tar -czf jniLibs.tar.gz jniLibs
curl -v -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file jniLibs.tar.gz ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/jniLibs.tar.gz
android_release:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: ubuntu-android
needs: [version, android_libs]
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Restore gradle cache
id: cache-gradle-restore
uses: actions/cache/restore@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: grim-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Download artifacts
run: |
cd android/app/src/main
curl -o jniLibs.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/jniLibs.tar.gz
tar -xzf jniLibs.tar.gz
rm jniLibs.tar.gz
- name: Setup build
run: |
chmod +x android/gradlew
echo "${{ secrets.ANDROID_KEYSTORE }}" > release.keystore.txt
base64 -d release.keystore.txt > android/keystore
echo "${{ secrets.ANDROID_KEYSTORE_PROPS }}" > release.keystore.props.txt
base64 -d release.keystore.props.txt > android/keystore.properties
mkdir ~/.gradle && touch ~/.gradle/gradle.properties
printf "mavenHost=${{ secrets.MAVEN_HOST }}\n" >> ~/.gradle/gradle.properties
printf "mavenUser=${{ secrets.MAVEN_USER }}\n" >> ~/.gradle/gradle.properties
printf "mavenPassword=${{ secrets.MAVEN_PASSWORD }}" >> ~/.gradle/gradle.properties
- name: Release ARMv7+v8 APK
working-directory: android
run: |
jni_path=app/src/main/jniLibs
mv ${jni_path}/x86_64 x86_64
./gradlew assembleCiSignedRelease
apk_path=app/build/outputs/apk/ci/signedRelease/app-ci-signedRelease.apk
name=grim-${{ needs.version.outputs.v }}-android.apk
mv ${apk_path} "${name}"
- name: Checksum ARM APK
working-directory: android
run: |
name=grim-${{ needs.version.outputs.v }}-android.apk
checksum=grim-${{ needs.version.outputs.v }}-android-sha256sum.txt
sha256sum "${name}" > "${checksum}"
- name: Release x86_64 APK
working-directory: android
run: |
./gradlew clean
jni_path=app/src/main/jniLibs
rm -rf ${jni_path}/*
mv x86_64 ${jni_path}
./gradlew assembleCiSignedRelease
apk_path=app/build/outputs/apk/ci/signedRelease/app-ci-signedRelease.apk
name=grim-${{ needs.version.outputs.v }}-android-x86_64.apk
mv ${apk_path} "${name}"
- name: Save gradle cache
uses: actions/cache/save@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
- name: Checksum x86_64 APK
working-directory: android
run: |
name=grim-${{ needs.version.outputs.v }}-android.apk
checksum=grim-${{ needs.version.outputs.v }}-android-x86_64-sha256sum.txt
sha256sum "${name}" > "${checksum}"
- name: Upload artifacts
run: |
mkdir release
mv android/grim* release
tar -czf android.tar.gz release
curl -v -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file android.tar.gz ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/android.tar.gz
linux_arm:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: ubuntu
needs: [version, android_libs]
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
- name: Release Linux ARM
run: cargo zigbuild --release --target aarch64-unknown-linux-gnu
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: AppImage ARM
run: |
mkdir release
cp target/aarch64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
appimagetool linux/Grim.AppDir grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
cp grim-${{ needs.version.outputs.v }}-linux-arm.AppImage release/
- name: Checksum AppImage ARM
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-linux-arm.AppImage > grim-${{ needs.version.outputs.v }}-linux-arm-appimage-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf linux-arm.tar.gz release
curl -v -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-arm.tar.gz ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/linux-arm.tar.gz
linux_x86:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: ubuntu-linux-x86
needs: [version]
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-linux-x86-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
- name: Release Linux x86
run: cargo zigbuild --release --target x86_64-unknown-linux-gnu
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: AppImage x86
run: |
mkdir release
cp target/x86_64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
appimagetool linux/Grim.AppDir grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
cp grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage release/
- name: Checksum AppImage x86
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage > grim-${{ needs.version.outputs.v }}-linux-x86_64-appimage-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf linux-x86_64.tar.gz release
curl -v -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-x86_64.tar.gz ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/linux-x86_64.tar.gz
macos:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: ubuntu
needs: [version, android_libs, linux]
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- run: mkdir release
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-macos-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
- name: Release MacOS Universal
run: |
cargo zigbuild --release --target universal2-apple-darwin
cp target/universal2-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: Archive Universal
working-directory: macos
run: |
zip -r grim-${{ needs.version.outputs.v }}-macos-universal.zip Grim.app
mv grim-${{ needs.version.outputs.v }}-macos-universal.zip ../release
- name: Checksum Release Universal
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-macos-universal.zip > grim-${{ needs.version.outputs.v }}-macos-universal-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf macos.tar.gz release
curl -v -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file macos.tar.gz ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/macos.tar.gz
windows:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: windows
needs: [version]
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- run: mkdir release
- name: Release Windows x86
run: |
cargo wix -p grim -o grim-${{ needs.version.outputs.v }}-win-x86_64.msi --nocapture
mv grim-${{ needs.version.outputs.v }}-win-x86_64.msi release\
Compress-Archive -Path target\release\grim.exe -DestinationPath grim-${{ needs.version.outputs.v }}-win-x86_64.zip
mv grim-${{ needs.version.outputs.v }}-win-x86_64.zip release\
- name: Checksum Archive x86
working-directory: release
run: |
certutil -hashfile grim-${{ needs.version.outputs.v }}-win-x86_64.msi SHA256 > grim-${{ needs.version.outputs.v }}-win-x86_64-msi-sha256sum.txt
certutil -hashfile grim-${{ needs.version.outputs.v }}-win-x86_64.zip SHA256 > grim-${{ needs.version.outputs.v }}-win-x86_64-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf windows.tar.gz release
Remove-Item alias:curl
curl -v -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file windows.tar.gz ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/windows.tar.gz
release:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: debian-release
needs: [version, android_release, linux, linux_x86, macos, windows]
steps:
- name: Download All Artifacts
run: |
curl -o android.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/android.tar.gz
tar -xzf android.tar.gz
rm android.tar.gz
curl -o linux-arm.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/linux-arm.tar.gz
tar -xzf linux-arm.tar.gz
rm linux-arm.tar.gz
curl -o linux-x86_64.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/linux-x86_64.tar.gz
tar -xzf linux-x86_64.tar.gz
rm linux-x86_64.tar.gz
curl -o macos.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/macos.tar.gz
tar -xzf macos.tar.gz
rm macos.tar.gz
curl -o windows.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci-artifacts/${{ forgejo.repository }}/windows.tar.gz
tar -xzf windows.tar.gz
rm windows.tar.gz
- name: Upload release to Forgejo
uses: ardocrat/forgejo-release@grim
with:
direction: upload
token: ${{ secrets.RELEASE_TOKEN }}
tag: ${{ needs.version.outputs.v }}
override: true
prerelease: ${{ needs.version.outputs.pre == 'true' }}
release-dir: release
release-notes: "Full Changelog: [${{ needs.version.outputs.last_tag }}...${{ needs.version.outputs.v }}](https://code.gri.mw/${{ forgejo.repository }}/compare/${{ needs.version.outputs.last_tag }}...${{ needs.version.outputs.v }})"
- name: Telegram Notify Channel
if: always()
uses: actions/telegram-notifier@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_CHANNEL_ID }}
status: ${{ job.status }}
notify_fields: "actor,repository,workflow,branch,commit"
- name: Telegram Notify Group
if: always()
uses: actions/telegram-notifier@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_GROUP_ID }}
status: ${{ job.status }}
notify_fields: "actor,repository,workflow,branch,commit"
- name: Upload files to Telegram
uses: actions/telegram-send-file@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
chat_ids: |
${{ secrets.TELEGRAM_CHANNEL_ID }}
${{ secrets.TELEGRAM_GROUP_ID }}
body: '🎁 Release <a href="https://code.gri.mw/${{ forgejo.repository }}/releases">${{ needs.version.outputs.v }}</a> is ready!'
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
pin: true
files: |
release/grim-${{ needs.version.outputs.v }}-android.apk
release/grim-${{ needs.version.outputs.v }}-android-x86_64.apk
release/grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
release/grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
release/grim-${{ needs.version.outputs.v }}-macos-universal.zip
release/grim-${{ needs.version.outputs.v }}-win-x86_64.msi
release/grim-${{ needs.version.outputs.v }}-win-x86_64.zip
+23
View File
@@ -0,0 +1,23 @@
name: Fetch patched nym SDK
description: >
Clone the patched nym workspace from our own mirror
(git.us-ea.st/GRIN/nym, branch `goblin` = upstream nymtech/nym @ b6eb391 +
Goblin's Android webpki-roots patch) into ../nym, so the
`nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }` dependency resolves.
Self-hosted: no upstream-GitHub fetch and no patch-apply step.
runs:
using: composite
steps:
- name: Clone patched nym
shell: bash
run: |
set -euo pipefail
DEST="$(dirname "$GITHUB_WORKSPACE")/nym"
if [ -e "$DEST/sdk/rust/nym-sdk/Cargo.toml" ]; then
echo "nym already present at $DEST"
exit 0
fi
rm -rf "$DEST"
git clone --branch goblin --depth 1 https://git.us-ea.st/GRIN/nym.git "$DEST"
echo "nym cloned from GRIN/nym@goblin -> $DEST"
-23
View File
@@ -1,23 +0,0 @@
#!/bin/bash
HOST=https://code.gri.mw
REPO_NAME=$1
TAG=$2
DOWNLOAD_URL=${HOST}/${REPO_NAME}/releases/download/${TAG}
FILES=( "grim-${TAG}-android.apk" "grim-${TAG}-android-x86_64.apk" "grim-${TAG}-linux-arm.AppImage" "grim-${TAG}-linux-x86_64.AppImage" "grim-${TAG}-macos-universal.zip" "grim-${TAG}-win-x86_64.msi" "grim-${TAG}-win-x86_64.zip" )
# Download release files
for f in "${FILES[@]}"; do
wget -q ${DOWNLOAD_URL}/${f}
echo Downloading ${f}...
while [ ! -f ${f} ]; do
sleep 5
echo Retry ${f}...
wget -q ${DOWNLOAD_URL}/${f}
done
done
# Save release notes
INFO_URL=${HOST}/api/v1/repos/${REPO_NAME}/releases/tags/${TAG}
curl -s "${INFO_URL}" | jq -r '.body' > release_notes.txt
+11 -1
View File
@@ -1,6 +1,12 @@
name: Build
on: [push, pull_request]
# aws-lc-sys (pulled in by nym-sdk) builds AWS-LC, which needs NASM on native
# Windows. Use the prebuilt NASM objects the crate ships so the runner doesn't
# need NASM installed; harmless on Linux/macOS.
env:
AWS_LC_SYS_PREBUILT_NASM: 1
jobs:
linux:
name: Linux Build
@@ -9,6 +15,8 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
# nym-sdk is a path dep on ../nym; materialize it before building.
- uses: ./.github/actions/fetch-nym
- name: Release build
run: cargo build --release
@@ -19,9 +27,10 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: ./.github/actions/fetch-nym
- name: Release build
run: cargo build --release
macos:
name: MacOS Build
runs-on: macos-latest
@@ -29,5 +38,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: ./.github/actions/fetch-nym
- name: Release build
run: cargo build --release
+99 -22
View File
@@ -1,31 +1,108 @@
# Release builds on native runners — one per platform, no cross-compilation
# (nokhwa's camera backends want each platform's own SDK; see NEXT-STEPS judgment).
#
# Manually triggered (Actions → Release → Run workflow) against an existing tag
# until a run has been validated end-to-end; then this can move to a tag trigger.
# Android is built locally via scripts/android.sh for now — the gradle `ci`
# flavor expects maven credentials this repository does not carry.
name: Release
on:
push:
tags:
- "v*.*.*"
# macOS is DEFERRED until Linux/Windows/Android are polished — so this is
# manual-dispatch only for now (no auto-build on release publish). When macOS
# is back on the table, re-add `release: { types: [published] }` here and the
# macOS job will attach a universal build to each release automatically.
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to build and upload artifacts to (e.g. build27)"
required: true
permissions:
contents: write
env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
# aws-lc-sys (via nym-sdk) needs NASM on native Windows; use its prebuilt NASM.
AWS_LC_SYS_PREBUILT_NASM: 1
jobs:
create_release:
name: Create Release
linux:
name: Linux x86_64
runs-on: ubuntu-latest
permissions:
contents: write
# Built locally and uploaded with the release; only run on manual dispatch.
if: github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download release
run: chmod +x .github/download_release.sh && .github/download_release.sh GUI/grim ${{ github.ref_name }}
- name: Release
uses: softprops/action-gh-release@v2
- uses: actions/checkout@v6
with:
body_path: release_notes.txt
overwrite_files: true
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-nym
- name: Build
shell: bash
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
- name: Package
run: |
tar -C target/release -czf "goblin-$TAG-linux-x86_64.tar.gz" goblin
sha256sum "goblin-$TAG-linux-x86_64.tar.gz" > "goblin-$TAG-linux-x86_64-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
grim-${{ github.ref_name }}-android.apk
grim-${{ github.ref_name }}-android-x86_64.apk
grim-${{ github.ref_name }}-linux-arm.AppImage
grim-${{ github.ref_name }}-linux-x86_64.AppImage
grim-${{ github.ref_name }}-macos-universal.zip
grim-${{ github.ref_name }}-win-x86_64.msi
grim-${{ github.ref_name }}-win-x86_64.zip
goblin-${{ inputs.tag || github.event.release.tag_name }}-linux-x86_64.tar.gz
goblin-${{ inputs.tag || github.event.release.tag_name }}-linux-x86_64-sha256sum.txt
windows:
name: Windows x86_64 (MSVC)
runs-on: windows-latest
# Built locally and uploaded with the release; only run on manual dispatch.
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-nym
- name: Build
shell: bash
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
- name: Package
shell: bash
run: |
7z a "goblin-$TAG-win-x86_64.zip" ./target/release/goblin.exe
sha256sum "goblin-$TAG-win-x86_64.zip" > "goblin-$TAG-win-x86_64-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64.zip
goblin-${{ inputs.tag || github.event.release.tag_name }}-win-x86_64-sha256sum.txt
macos:
name: macOS universal
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-nym
- name: Build both architectures
run: |
export GOBLIN_BUILD="${TAG#build}"
rustup target add aarch64-apple-darwin x86_64-apple-darwin
cargo build --release --target aarch64-apple-darwin
cargo build --release --target x86_64-apple-darwin
- name: Universal binary + package
run: |
lipo -create -output goblin \
target/aarch64-apple-darwin/release/goblin \
target/x86_64-apple-darwin/release/goblin
zip "goblin-$TAG-macos-universal.zip" goblin
shasum -a 256 "goblin-$TAG-macos-universal.zip" > "goblin-$TAG-macos-universal-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal.zip
goblin-${{ inputs.tag || github.event.release.tag_name }}-macos-universal-sha256sum.txt
-170
View File
@@ -1,170 +0,0 @@
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
linux_release:
name: Linux Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download appimagetools
run: |
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
sudo apt install libfuse2
- name: Zig Setup
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.12.1
- name: Install cargo-zigbuild
run: cargo install cargo-zigbuild
- name: Release x86
run: cargo zigbuild --release --target x86_64-unknown-linux-gnu
- name: Release ARM
run: |
rustup target add aarch64-unknown-linux-gnu
cargo zigbuild --release --target aarch64-unknown-linux-gnu
- name: AppImage x86
run: |
cp target/x86_64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
./appimagetool-x86_64.AppImage linux/Grim.AppDir target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64.AppImage
- name: Checksum AppImage x86
working-directory: target/x86_64-unknown-linux-gnu/release
shell: bash
run: sha256sum grim-${{ github.ref_name }}-linux-x86_64.AppImage > grim-${{ github.ref_name }}-linux-x86_64-appimage-sha256sum.txt
- name: AppImage ARM
run: |
cp target/aarch64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
./appimagetool-x86_64.AppImage linux/Grim.AppDir target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm.AppImage
- name: Checksum AppImage ARM
working-directory: target/aarch64-unknown-linux-gnu/release
shell: bash
run: sha256sum grim-${{ github.ref_name }}-linux-arm.AppImage > grim-${{ github.ref_name }}-linux-arm-appimage-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64.AppImage
target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64-appimage-sha256sum.txt
target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm.AppImage
target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm-appimage-sha256sum.txt
windows_release:
name: Windows Release
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build release
run: cargo build --release
- name: Archive release
uses: vimtor/action-zip@v1
with:
files: target/release/grim.exe
dest: target/release/grim-${{ github.ref_name }}-win-x86_64.zip
- name: Checksum release
working-directory: target/release
shell: bash
run: sha256sum grim-${{ github.ref_name }}-win-x86_64.zip > grim-${{ github.ref_name }}-win-x86_64-sha256sum.txt
- name: Install cargo-wix
run: cargo install cargo-wix
- name: Run cargo-wix
run: cargo wix -p grim -o ./target/wix/grim-${{ github.ref_name }}-win-x86_64.msi --nocapture
- name: Checksum msi
working-directory: target/wix
shell: bash
run: sha256sum grim-${{ github.ref_name }}-win-x86_64.msi > grim-${{ github.ref_name }}-win-x86_64-msi-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
target/release/grim-${{ github.ref_name }}-win-x86_64.zip
target/release/grim-${{ github.ref_name }}-win-x86_64-sha256sum.txt
target/wix/grim-${{ github.ref_name }}-win-x86_64.msi
target/wix/grim-${{ github.ref_name }}-win-x86_64-msi-sha256sum.txt
macos_release:
name: MacOS Release
runs-on: macos-12
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install coreutils
run: brew install coreutils
- name: Zig Setup
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.12.1
- name: Install cargo-zigbuild
run: cargo install cargo-zigbuild
- name: Download SDK
run: wget https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX11.0.sdk.tar.xz
- name: Setup SDK env
run: tar xf ${{ github.workspace }}/MacOSX11.0.sdk.tar.xz && echo "SDKROOT=${{ github.workspace }}/MacOSX11.0.sdk" >> $GITHUB_ENV
- name: Setup platform env
run: echo "MACOSX_DEPLOYMENT_TARGET=11.0" >> $GITHUB_ENV
- name: Release x86
run: |
rustup target add x86_64-apple-darwin
cargo zigbuild --release --target x86_64-apple-darwin
yes | cp -rf target/x86_64-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive x86
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-x86_64.zip Grim.app
mv grim-${{ github.ref_name }}-macos-x86_64.zip ../target/x86_64-apple-darwin/release
cd ..
- name: Checksum Release x86
working-directory: target/x86_64-apple-darwin/release
shell: bash
run: sha256sum grim-${{ github.ref_name }}-macos-x86_64.zip > grim-${{ github.ref_name }}-macos-x86_64-sha256sum.txt
- name: Release ARM
run: |
rustup target add aarch64-apple-darwin
cargo zigbuild --release --target aarch64-apple-darwin
yes | cp -rf target/aarch64-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive ARM
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-arm.zip Grim.app
mv grim-${{ github.ref_name }}-macos-arm.zip ../target/aarch64-apple-darwin/release
cd ..
- name: Checksum Release ARM
working-directory: target/aarch64-apple-darwin/release
shell: bash
run: sha256sum grim-${{ github.ref_name }}-macos-arm.zip > grim-${{ github.ref_name }}-macos-arm-sha256sum.txt
- name: Release Universal
run: |
cargo zigbuild --release --target universal2-apple-darwin
yes | cp -rf target/universal2-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive Universal
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-universal.zip Grim.app
mv grim-${{ github.ref_name }}-macos-universal.zip ../target/universal2-apple-darwin/release
cd ..
- name: Checksum Release Universal
working-directory: target/universal2-apple-darwin/release
shell: pwsh
run: sha256sum grim-${{ github.ref_name }}-macos-universal.zip > grim-${{ github.ref_name }}-macos-universal-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
target/x86_64-apple-darwin/release/grim-${{ github.ref_name }}-macos-x86_64.zip
target/x86_64-apple-darwin/release/grim-${{ github.ref_name }}-macos-x86_64-sha256sum.txt
target/aarch64-apple-darwin/release/grim-${{ github.ref_name }}-macos-arm.zip
target/aarch64-apple-darwin/release/grim-${{ github.ref_name }}-macos-arm-sha256sum.txt
target/universal2-apple-darwin/release/grim-${{ github.ref_name }}-macos-universal.zip
target/universal2-apple-darwin/release/grim-${{ github.ref_name }}-macos-universal-sha256sum.txt
+5 -2
View File
@@ -18,6 +18,9 @@ target
.cargo/
app/src/main/jniLibs
macos/cert.pem
linux/Grim.AppDir/AppRun
linux/Goblin.AppDir/AppRun
.intentionally-empty-file.o
Cargo.toml-e
Cargo.toml-e
screenshots/
# GRIM-canonical build toolchains fetched by scripts/toolchain.sh
.toolchains/
-4
View File
@@ -5,7 +5,3 @@
path = wallet
url = https://code.gri.mw/ardocrat/wallet
branch = grim
[submodule "tor/webtunnel"]
path = tor/webtunnel
url = https://code.gri.mw/WEB/webtunnel
branch = grim
+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
+5057 -2429
View File
File diff suppressed because it is too large Load Diff
+58 -23
View File
@@ -1,22 +1,28 @@
[package]
name = "grim"
version = "0.3.1"
version = "0.3.6"
authors = ["Ardocrat <ardocrat@gri.mw>"]
description = "Cross-platform GUI for Grin with focus on usability and availability to be used by anyone, anywhere."
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and the Nym mixnet handled for you."
license = "Apache-2.0"
repository = "https://code.gri.mw/GUI/grim"
keywords = [ "crypto", "grin", "mimblewimble" ]
edition = "2021"
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
@@ -52,6 +58,7 @@ egui-async = "0.3.4"
rust-i18n = "3.1.5"
## other
log4rs = "1.4.0"
anyhow = "1.0.97"
pin-project = "1.1.10"
backtrace = "0.3.76"
@@ -86,28 +93,46 @@ bytes = "1.11.0"
hyper-socks2 = "0.9.1"
hyper-proxy2 = "0.1.0"
hyper-tls = "0.6.0"
## native-tls (via hyper-tls) uses OpenSSL on Linux/Android. Upstream Grim got a
## vendored, statically-linked OpenSSL for free through arti's `static` feature;
## dropping arti for Nym took that with it, breaking Android/cross builds (no
## system OpenSSL for the target) and leaving desktop dynamically linked to
## libssl. Restore the vendored build so every target is self-contained. Inert on
## Windows/macOS, which use SChannel / Security.framework instead of OpenSSL.
openssl = { version = "0.10", features = ["vendored"] }
async-std = "1.13.2"
uuid = { version = "0.8.2", features = ["v4"] }
num-bigint = "0.4.6"
## tor
arti-client = { version = "0.38.0", features = ["pt-client", "static", "onion-service-service", "onion-service-client"] }
tor-rtcompat = { version = "0.38.0", features = ["static"] }
tor-config = "0.38.0"
fs-mistrust = "0.13.1"
tor-hsservice = "0.38.0"
tor-hsrproxy = "0.38.0"
tor-keymgr = "0.38.0"
tor-llcrypto = "0.38.0"
tor-hscrypto = "0.38.0"
tor-error = "0.38.0"
## nostr
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
nostr-relay-pool = "0.44"
async-wsocket = "0.13"
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
regex = "1"
base64 = "0.22"
hex = "0.4"
## HTTP client routed through the local Nym SOCKS5 sidecar (rustls, no native
## TLS so it cross-compiles to Android; `socks` so every request — NIP-05,
## price, avatars — goes over the mixnet, never clearnet).
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks"] }
## SOCKS5 TCP dialer for the nostr relay WebSocket transport over the mixnet.
tokio-socks = "0.5"
## rustls is pulled by both our TLS (tungstenite/reqwest, ring) and nym-sdk
## (aws-lc-rs); with two providers present rustls 0.23 can't auto-pick a default,
## so we install ring explicitly at startup (see lib.rs). Direct dep just to make
## `rustls::crypto::ring::default_provider()` reachable.
rustls = { version = "0.23", features = ["ring"] }
## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary). We
## run the SDK's SOCKS5 client on an internal tokio task exposing 127.0.0.1:1080,
## the same loopback seam the transport already dials. Path dep: the local nym
## checkout carries our Android webpki-roots patch.
nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
## NIP-98 payload hashing
sha2 = "0.10.8"
ed25519-dalek = "2.1.1"
curve25519-dalek = "4.1.3"
hyper-tor = { version = "0.14.32", features = ["full"], package = "hyper" }
tls-api = "0.12.0"
tls-api-native-tls = "0.12.1"
safelog = "0.7.0"
## stratum server
tokio-old = { version = "0.2", features = ["full"], package = "tokio" }
@@ -136,4 +161,14 @@ android_logger = "0.15.0"
jni = "0.21.1"
android-activity = { version = "0.6.0", features = ["game-activity"] }
winit = { version = "0.30.12", features = ["android-game-activity"] }
eframe = { version = "0.33.2", default-features = false, features = ["glow", "android-game-activity"] }
eframe = { version = "0.33.2", default-features = false, features = ["glow", "android-game-activity"] }
[build-dependencies]
built = "0.8.0"
[dev-dependencies]
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
tokio = { version = "1.49.0", features = ["full"] }
base64 = "0.22"
sha2 = "0.10"
hex = "0.4"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+52 -18
View File
@@ -1,39 +1,73 @@
# Grim <img height="20" src="img/grin-logo.png"/> <img height="20" src="img/logo.png"/>
Cross-platform GUI for [GRiN ツ](https://grin.mw) in [Rust](https://www.rust-lang.org/)
for maximum compatibility with original [Mimblewimble](https://github.com/mimblewimble/grin) implementation.
Initially supported platforms are Linux, Mac, Windows, limited Android and possible web support with help of [egui](https://github.com/emilk/egui) - immediate mode GUI library in pure Rust.
<p align="center">
<img src="Goblin-Banner.png" alt="Goblin" width="680"/>
</p>
Named by the character [Grim](http://harrypotter.wikia.com/wiki/Grim) - the shape of a large, black, menacing, spectral giant dog.
# Goblin
![image](img/cover.png)
Goblin is a private, Cash App-style wallet for [GRIN ツ](https://grin.mw) — confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain.
Instead of passing slatepack files back and forth, you **pay a `@username` (or an `npub`)** and the payment is delivered for you as an **end-to-end encrypted message over [nostr](https://github.com/nostr-protocol/nips), routed through the [Nym mixnet](https://nym.com)**. Relays only ever see ciphertext — never the amount, the sender, or the recipient — and the mixnet hides who is talking to whom at the network layer.
## Build instructions
### Install Rust
Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN node/wallet engine and layers a Nostr-native, mobile-first payments experience on top.
Follow instructions on [Windows](https://forge.rust-lang.org/infra/other-installation-methods.html).
## What it does
`curl https://sh.rustup.rs -sSf | sh`
- **Send to people** — pay a `@username` or `npub`; the GRIN slatepack travels as a [NIP-17](https://nips.nostr.com/17) gift-wrapped DM ([kind 1059](https://nostrbook.dev/kinds/1059)) over the Nym mixnet and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once.
- **In-app identity** — a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `@name` (and hosted avatar) comes from the goblin.st identity service.
- **Private by construction** — GRIN's address-less, confidential chain; every relay and HTTP request (relays, NIP-05 lookups, price, avatars) routed through the [Nym mixnet](https://nym.com) via a bundled `nym-socks5-client` sidecar, so nothing touches the clear net; keys, names and history stay on your device.
- **Configurable amount pairing** — show balances against a world currency, Bitcoin, or sats (rates fetched over the mixnet), or turn the preview off.
- **Cross-platform** — Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
### Desktop
## How a payment travels
To build and run application go to project directory and run:
```
you ──slatepack──▶ NIP-17 gift wrap (kind 1059, NIP-44 encrypted)
Nym mixnet (5-hop)
┌─────────────┴─────────────┐
your relays recipient's DM relays (kind 10050)
└─────────────┬─────────────┘
recipient ◀──unwrap, verify seal author, apply slatepack
```
The wrap is [NIP-44](https://nips.nostr.com/44)-encrypted, and delivery uses the recipient's DM relay list ([kind 10050](https://nostrbook.dev/kinds/10050)).
Both parties only need one relay in common. The default set is the Goblin relay plus large public relays (`relay.damus.io`, `nos.lol`), and the set is editable in **Settings → Relays**.
## Build
### Desktop (Linux / macOS / Windows)
```
git submodule update --init --recursive
cargo build --release
./target/release/grim
./target/release/goblin
```
Goblin routes all of its traffic over the [Nym mixnet](https://nym.com) using a `nym-socks5-client` sidecar that runs alongside the wallet and exposes a local SOCKS5 proxy on `127.0.0.1:1080`. Ship the `nym-socks5-client` binary next to the `goblin` executable (or point `GOBLIN_NYM_BIN` at it), and set the network requester it routes through via `GOBLIN_NYM_PROVIDER` (or bake it into `NETWORK_REQUESTER` in `src/nym/sidecar.rs`). If a SOCKS5 endpoint is already listening on `127.0.0.1:1080`, Goblin reuses it.
### Android
#### Set up the environment
Install Android SDK / NDK / Platform Tools for your OS according to this [FAQ](https://github.com/codepath/android_guides/wiki/installing-android-sdk-tools).
Install the Android SDK / NDK, then from the repo root:
#### Build the project
Run Android emulator or connect a real device. Command `adb devices` should show at least one device.
In the root of the repo run `./scripts/android.sh build|release v7|v8|x86`, where is `v7`, `v8`, `x86` - device CPU architecture for `build` type, for `release` specify version number in format `major.minor.patch`.
```
./scripts/android.sh build|release v7|v8|x86
```
`v7`/`v8`/`x86` is the device CPU architecture for `build`; for `release` pass a version in `major.minor.patch` form.
## Identity service (`goblin-nip05d`)
The optional `@name` + avatar service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration/transfer/release, and a hardened avatar pipeline (magic-byte sniffing, bounded decode, full re-encode to a clean 256×256 PNG). The wallet is fully usable — and fully anonymous — without it.
## License
Apache License v2.0.
## Credits
🤖 Built with AI pair-programming assistance (Claude)
The underlying cross-platform GRIN wallet engine is the upstream **Grim** project.
+5 -3
View File
@@ -8,11 +8,11 @@ android {
buildToolsVersion = '36.1.0'
defaultConfig {
applicationId "mw.gri.android"
applicationId "st.goblin.wallet"
minSdk 24
targetSdk 36
versionCode 5
versionName "0.3.1"
versionName "0.3.6"
}
lint {
@@ -71,7 +71,9 @@ android {
applicationVariants.all { variant ->
def flavor = variant.productFlavors[0].name
if (flavor == "ci") {
// The ci branch reads the private-mirror properties at configuration time,
// which runs for every variant — only enter it when the mirror is configured.
if (flavor == "ci" && project.hasProperty("mavenHost")) {
repositories {
maven {
credentials {
+5 -3
View File
@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
@@ -18,7 +19,7 @@
android:largeHeap="true"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Grim"
android:label="Goblin"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Main"
android:enableOnBackInvokedCallback="false"
@@ -29,7 +30,7 @@
<provider
android:name=".FileProvider"
android:authorities="mw.gri.android.fileprovider"
android:authorities="st.goblin.wallet.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
@@ -40,7 +41,7 @@
<activity
android:launchMode="singleTask"
android:name=".MainActivity"
android:configChanges="orientation|screenSize"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode|density|locale|layoutDirection|fontScale|colorMode"
android:exported="true">
<intent-filter>
@@ -54,6 +55,7 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="application/*" />
<data android:pathPattern=".*\\.slatepack" />
</intent-filter>
@@ -331,6 +331,17 @@ public class MainActivity extends GameActivity {
onTextInput("9");
return false;
}
} else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
if (!event.getCharacters().isEmpty()) {
onTextInput(event.getCharacters());
return false;
}
// Pass any other input values into native code.
} else if (event.getAction() == KeyEvent.ACTION_UP &&
event.getKeyCode() != KeyEvent.KEYCODE_ENTER &&
event.getKeyCode() != KeyEvent.KEYCODE_BACK) {
onTextInput(String.valueOf((char)event.getUnicodeChar()));
return false;
}
return super.dispatchKeyEvent(event);
}
@@ -355,18 +366,25 @@ public class MainActivity extends GameActivity {
@Override
protected void onDestroy() {
unregisterReceiver(mBroadcastReceiver);
BackgroundService.stop(this);
// Kill process after 3 secs if app was terminated from recent apps to prevent app hang.
new Thread(() -> {
try {
onTermination();
Thread.sleep(3000);
Process.killProcess(Process.myPid());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
// Only tear the process down when the activity is actually finishing.
// onDestroy also fires for configuration-change recreations (rotation,
// density, uiMode); killing the process there takes the whole app down
// right as Android is about to recreate the activity.
if (isFinishing()) {
BackgroundService.stop(this);
// Kill process after 3 secs if app was terminated from recent apps to prevent app hang.
new Thread(() -> {
try {
onTermination();
Thread.sleep(3000);
Process.killProcess(Process.myPid());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
}
super.onDestroy();
}
@@ -485,13 +503,51 @@ public class MainActivity extends GameActivity {
// Called from native code to share data from provided path.
public void shareData(String path) {
File file = new File(path);
Uri uri = FileProvider.getUriForFile(this, "mw.gri.android.fileprovider", file);
Uri uri = FileProvider.getUriForFile(this, "st.goblin.wallet.fileprovider", file);
Intent intent = new Intent(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.setType("text/*");
startActivity(Intent.createChooser(intent, "Share data"));
}
// Called from native code to share plain text (e.g. a payment link) via the
// system share sheet.
public void shareText(String text) {
Intent intent = new Intent(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_TEXT, text);
intent.setType("text/plain");
startActivity(Intent.createChooser(intent, "Share"));
}
// Called from native code to play a short "error" haptic (rejected payment).
public void vibrateError() {
Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
if (vibrator == null || !vibrator.hasVibrator()) {
return;
}
// Two short pulses read as "no" / rejected, distinct from a tap.
long[] pattern = new long[]{0, 40, 70, 40};
if (Build.VERSION.SDK_INT >= 26) {
vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1));
} else {
vibrator.vibrate(pattern, -1);
}
}
// Called from native code to set status-bar icon color to contrast the
// in-app theme. white = light icons for a dark background. The app draws
// edge-to-edge, so the OS status-bar background is the app's own content;
// the icons must follow the theme or they vanish (dark-on-dark).
public void setStatusBarWhiteIcons(boolean white) {
runOnUiThread(() -> {
androidx.core.view.WindowInsetsControllerCompat c =
androidx.core.view.WindowCompat.getInsetsController(getWindow(),
getWindow().getDecorView());
// isAppearanceLightStatusBars == true means DARK icons.
c.setAppearanceLightStatusBars(!white);
});
}
// Called from native code to check if device is using dark theme.
public boolean useDarkTheme() {
int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 30 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FCEF03</color>
<color name="ic_launcher_background">#FFD60A</color>
</resources>
+20 -23
View File
@@ -1,29 +1,26 @@
pluginManagement {
repositories {
maven {
credentials {
username "$mavenUser"
password "$mavenPassword"
// Private mirror only when its coordinates are supplied (-PmavenHost=… as in upstream CI);
// everyone else resolves plugins from the public repositories.
def mavenHost = providers.gradleProperty("mavenHost")
if (mavenHost.present) {
def mavenUser = providers.gradleProperty("mavenUser").get()
def mavenPassword = providers.gradleProperty("mavenPassword").get()
["gradle-plugin-portal", "google-maven", "maven-central"].each { repo ->
maven {
credentials {
username mavenUser
password mavenPassword
}
url "${mavenHost.get()}/repository/${repo}/"
allowInsecureProtocol = true
}
}
url "$mavenHost/repository/gradle-plugin-portal/"
allowInsecureProtocol = true
}
maven {
credentials {
username "$mavenUser"
password "$mavenPassword"
}
url "$mavenHost/repository/google-maven/"
allowInsecureProtocol = true
}
maven {
credentials {
username "$mavenUser"
password "$mavenPassword"
}
url "$mavenHost/repository/maven-central/"
allowInsecureProtocol = true
} else {
gradlePluginPortal()
google()
mavenCentral()
}
}
}
include ':app'
include ':app'
+56 -68
View File
@@ -1,74 +1,62 @@
use std::env;
use std::path::PathBuf;
use std::process::Command;
use std::{env, fs};
/// The GRIM commit Goblin forked from; builds count commits on top of it.
const GOBLIN_FORK_BASE: &str = "b51a46b";
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let tor_out_dir = format!("{}/tor", out_dir);
let mut webtunnel_file = format!("{}/webtunnel", tor_out_dir);
let exists = fs::exists(&webtunnel_file).unwrap();
if !exists {
// Create empty webtunnel file to allow build with include_bytes! macro.
fs::create_dir(&tor_out_dir).unwrap_or_default();
fs::File::create(&webtunnel_file).unwrap();
}
built::write_built_file().expect("Failed to acquire build-time information");
let target = env::var("TARGET").unwrap();
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
// 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");
let is_android = target_os == "android";
if is_android {
// Set a path to Android Webtunnel binary.
let arch = if target.contains("aarch64") {
"arm64-v8a"
} else if target.contains("arm") {
"armeabi-v7a"
} else {
"x86_64"
};
let root = env::var("CARGO_MANIFEST_DIR").unwrap();
webtunnel_file = format!("{}/android/app/src/main/jniLibs/{}/libwebtunnel.so", root, arch);
}
// 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()
);
// Build if Webtunnel binary is empty or not exists.
let empty = match fs::File::open(&webtunnel_file) {
Ok(file) => file.metadata().unwrap().len() == 0,
Err(_) => true
};
let build = !exists || empty;
if build {
// Setup GOOS env variable.
let go_os = if target_os == "macos" {
"darwin"
} else {
target_os.as_str()
};
// Setup GOARCH env variable.
let go_arch = if target.contains("aarch64") {
"arm64"
} else if target.contains("arm") {
"arm"
} else {
"amd64"
};
// Run Webtunnel Go build.
let output = if env::consts::OS == "windows" {
Command::new("./scripts/webtunnel.bat")
.arg(go_os)
.arg(go_arch)
.arg(webtunnel_file)
.output()
} else {
Command::new("bash")
.arg("./scripts/webtunnel.sh")
.arg(go_os)
.arg(go_arch)
.arg(webtunnel_file)
.output()
};
if let Ok(out) = output {
if out.status.code().is_none() || out.status.code().unwrap() != 0 {
panic!("webtunnel go build failed:\n{:?}", out);
}
}
}
}
if cfg!(target_os = "windows") {
Command::new("cmd")
.args(&["/C", &git_hooks])
.output()
.expect("failed to execute git config for hooks");
} else {
Command::new("sh")
.args(&["-c", &git_hooks])
.output()
.expect("failed to execute git config for hooks");
}
// Goblin links the Nym mixnet SDK in-process (see src/nym/) — no sidecar
// subprocess, no bundled/embedded helper binary, and no Tor/webtunnel. There
// is nothing transport-related to build or embed here.
}
+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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 37 KiB

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

After

Width:  |  Height:  |  Size: 37 KiB

-1
View File
@@ -1 +0,0 @@
grim.png
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

+50 -20
View File
@@ -1,27 +1,57 @@
#!/bin/bash
#
# Build a portable, single-file Goblin AppImage.
#
# Usage: linux/build_release.sh [platform]
# platform: 'x86_64' (default) or 'arm'
#
# Goblin links the Nym SDK IN-PROCESS (src/nym/), so the AppImage is one
# self-contained binary with no sidecar to embed or ship beside it.
case $2 in
x86_64|arm)
;;
*)
echo "Usage: release_linux.sh [platform] [version]\n - platform: 'x86_64', 'arm'" >&2
exit 1
set -euo pipefail
platform="${1:-x86_64}"
case "${platform}" in
x86_64) arch="x86_64-unknown-linux-gnu" ;;
arm) arch="aarch64-unknown-linux-gnu" ;;
*) echo "Usage: build_release.sh [platform] (platform: 'x86_64' | 'arm')" >&2; exit 1 ;;
esac
# Setup build directory
BASEDIR=$(cd $(dirname $0) && pwd)
cd ${BASEDIR}
cd ..
# Repo root (this script lives in linux/).
BASEDIR=$(cd "$(dirname "$0")" && pwd)
cd "${BASEDIR}/.."
# Setup platform argument
[[ $2 == "x86_64" ]] && arch+=(x86_64-unknown-linux-gnu)
[[ $2 == "arm" ]] && arch+=(aarch64-unknown-linux-gnu)
# Prefer the GRIM-canonical toolchains (zig + appimagetool from code.gri.mw/DEV);
# scripts/toolchain.sh fetches them and writes this env. Falls back to system
# installs when it's absent.
[ -f .toolchains/env.sh ] && source .toolchains/env.sh
rustup target add ${arch}
cargo install cargo-zigbuild
cargo zigbuild --release --target ${arch}
rustup target add "${arch}"
command -v cargo-zigbuild >/dev/null || cargo install cargo-zigbuild
# Create AppImage with https://github.com/AppImage/appimagetool
cp target/${arch}/release/grim linux/Grim.AppDir/AppRun
rm target/${arch}/release/*.AppImage
appimagetool linux/Grim.AppDir target/${arch}/release/grim-v$2-linux-$1.AppImage
# Portable cross-build to glibc 2.17. Three zig-specific fixes:
# - CRoaring's AVX512 path won't compile under zig's clang (evex512 error).
# - OpenSSL is vendored in Cargo.toml, so no system libssl is needed.
# - v4l2-sys (camera/QR backend) runs bindgen over linux/videodev2.h, a kernel
# UAPI header missing from zig 0.12.1's glibc-2.17 sysroot; point bindgen at
# the host's kernel headers. This only reads struct layouts — the actual libc
# linkage stays glibc-2.17, so portability is unaffected.
export CFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0"
export CXXFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0"
export BINDGEN_EXTRA_CLANG_ARGS="${BINDGEN_EXTRA_CLANG_ARGS:-} -I/usr/include"
cargo zigbuild --release --target "${arch}.2.17"
# Assemble the AppDir: AppRun IS the goblin binary (Nym SDK linked in), plus the
# icon + desktop entry. Nothing else.
appdir="linux/Goblin.AppDir"
cp "target/${arch}/release/goblin" "${appdir}/AppRun"
chmod +x "${appdir}/AppRun"
out="target/${arch}/release/Goblin-${platform}.AppImage"
rm -f "target/${arch}/release/"*.AppImage
# Use the DEV appimagetool + type2 runtime when fetched, else the system tool.
appimagetool_bin="${GOBLIN_APPIMAGETOOL:-appimagetool}"
runtime_arg=()
[ -n "${GOBLIN_APPIMAGE_RUNTIME:-}" ] && runtime_arg=(--runtime-file "${GOBLIN_APPIMAGE_RUNTIME}")
ARCH=x86_64 "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
echo "built: ${out}"
+3 -2
View File
@@ -142,6 +142,7 @@ wallets:
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
@@ -299,8 +300,8 @@ 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
+5 -4
View File
@@ -49,7 +49,7 @@ wallets:
add: Add wallet
name: 'Name:'
pass: 'Password:'
pass_empty: Enter password from the wallet
pass_empty: Enter the wallet password
current_pass: 'Current password:'
new_pass: 'New password:'
min_tx_conf_count: 'Minimum amount of confirmations for transactions:'
@@ -142,6 +142,7 @@ wallets:
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
@@ -179,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
@@ -299,8 +300,8 @@ 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
+3 -2
View File
@@ -142,6 +142,7 @@ wallets:
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
@@ -299,8 +300,8 @@ 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
+3 -2
View File
@@ -142,6 +142,7 @@ wallets:
payment_proof_desc: 'Введите полученное подтверждение оплаты для проверки транзакции:'
payment_proof_valid: 'Введённое подтверждение оплаты действительно:'
payment_proof_error: 'Введённое подтверждение оплаты недействительно:'
tx_delete_confirmation: Вы уверены, что хотите удалить транзакцию из истории?
transport:
desc: 'Используйте транспорт для синхронных получения или отправки сообщений:'
tor_network: Сеть Tor
@@ -299,8 +300,8 @@ network_settings:
ban_window_desc: Решение о запрете принимается узлом, основываясь на корректности данных полученных от пира.
max_inbound_count: 'Максимальное количество входящих подключений пиров:'
max_outbound_count: 'Максимальное количество исходящих подключений к пирам:'
reset_peers_desc: Сбросить данные пиров. Используйте с осторожностью, только при наличии проблем с поиском пиров.
reset_peers: Сбросить пиры
reset_data_desc: Сбросить данные узла. Используйте с осторожностью, только при наличии проблем с синхронизацией.
reset_data: Сброс данных
modal:
cancel: Отмена
save: Сохранить
+3 -2
View File
@@ -142,6 +142,7 @@ wallets:
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
@@ -299,8 +300,8 @@ 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
+3 -2
View File
@@ -142,6 +142,7 @@ wallets:
payment_proof_desc: '輸入已收款證明以驗證交易:'
payment_proof_valid: '輸入的付款證明有效:'
payment_proof_error: '輸入的付款證明無效:'
tx_delete_confirmation: 你確定要從歷史紀錄中刪除這筆交易嗎?
transport:
desc: '使用传输同步接收或发送消息:'
tor_network: Tor 网络
@@ -299,8 +300,8 @@ network_settings:
ban_window_desc: 禁止的决定是由节点 根据从网络对点收到的数据的正确性做出的.
max_inbound_count: '入站网络对点连接的最大数量:'
max_outbound_count: '最大出站网络对点连接数:'
reset_peers_desc: 重置网络对点数据。仅当查找网络对点出现问题时,才请谨慎使用.
reset_peers: 重置网络对点
reset_data_desc: 重置点数据。只有在出现同步问题时才需谨慎使用.
reset_data: 重置数据
modal:
cancel: 取消
save: 保存
@@ -5,9 +5,9 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Grim</string>
<string>Goblin</string>
<key>CFBundleExecutable</key>
<string>grim</string>
<string>goblin</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
@@ -17,11 +17,11 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Grim</string>
<string>Goblin</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.3</string>
<string>0.3.6</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
@@ -29,7 +29,7 @@
<key>CFBundleVersion</key>
<string>1</string>
<key>NSCameraUsageDescription</key>
<string>Grim needs an access to your camera to scan QR code.</string>
<string>Goblin needs an access to your camera to scan QR code.</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
+4 -4
View File
@@ -35,14 +35,14 @@ cargo zigbuild --release --target ${arch}
rm -f .intentionally-empty-file.o
yes | cp -rf target/${arch}/release/grim macos/Grim.app/Contents/MacOS
yes | cp -rf target/${arch}/release/goblin macos/Goblin.app/Contents/MacOS
# Sign .app resources on change:
#rcodesign generate-self-signed-certificate
#rcodesign sign --pem-file cert.pem macos/Grim.app
#rcodesign sign --pem-file cert.pem macos/Goblin.app
# Create release package
FILE_NAME=grim-v$2-macos-$1.zip
FILE_NAME=goblin-v$2-macos-$1.zip
cd macos
zip -r ${FILE_NAME} Grim.app
zip -r ${FILE_NAME} Goblin.app
mv ${FILE_NAME} ../target/${arch}/release
+1 -1
Submodule node updated: 2ec7b4d5cd...386ac1ed5c
+2
View File
@@ -0,0 +1,2 @@
hard_tabs = true
edition = "2024"
+8 -3
View File
@@ -24,6 +24,11 @@ BASEDIR=$(cd "$(dirname "$0")" && pwd)
cd "${BASEDIR}" || exit 1
cd ..
# Prefer the GRIM-canonical toolchain: the custom NDK r29 (rebuilt LLVM, 16 KB
# page-aligned) + Android SDK from code.gri.mw/DEV. scripts/toolchain.sh fetches
# them and writes this env; falls back to whatever NDK/SDK is on the system.
[ -f .toolchains/env.sh ] && source .toolchains/env.sh
# Install platforms and tools
rustup target add armv7-linux-androideabi
rustup target add aarch64-linux-android
@@ -41,16 +46,16 @@ function build_lib() {
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
sed -i -e 's/"rlib"]/"cdylib","rlib"]/g' Cargo.toml
# Fix for https://stackoverflow.com/questions/57193895/error-use-of-undeclared-identifier-pthread-mutex-robust-cargo-build-liblmdb-s
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0"
cargo ndk -t "${arch}" -o android/app/src/main/jniLibs build --profile release-apk
if [ $? -ne 0 ]; then
success=0
fi
unset CPPFLAGS && unset CFLAGS
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
rm -f Cargo.toml-e
# The Nym mixnet is linked INTO libgrim.so (nym-sdk is a regular dependency),
# so there is no separate sidecar binary to cross-build or bundle into jniLibs.
}
### Build application
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Generate all Goblin app icons from img/goblin-icon.png (app icon)
# and img/goblin-mask.png (black mascot art on transparency).
# Requires ImageMagick (magick).
set -euo pipefail
cd "$(dirname "$0")/.."
ICON=img/goblin-icon.png
MASK=img/goblin-mask.png
RES=android/app/src/main/res
# Desktop window icon + in-app embeds.
magick "$ICON" -resize 256x256 img/icon.png
magick "$ICON" -resize 512x512 img/goblin-icon-512.png
magick "$MASK" -channel RGB -fill white -colorize 100 img/goblin-mask-white.png
magick img/goblin-mask-white.png -resize 128x128 img/goblin-mask-128.png
magick img/goblin-mask-white.png -resize 64x64 img/goblin-mask-64.png
# Android launcher icons.
declare -A SIZES=( [mdpi]=48 [hdpi]=72 [xhdpi]=96 [xxhdpi]=144 [xxxhdpi]=192 )
declare -A FG_SIZES=( [mdpi]=108 [hdpi]=162 [xhdpi]=216 [xxhdpi]=324 [xxxhdpi]=432 )
for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
s=${SIZES[$d]}; fg=${FG_SIZES[$d]}
# mascot occupies ~52% of the adaptive canvas (safe zone is 66%)
art=$(( fg * 52 / 100 ))
magick "$ICON" -resize ${s}x${s} "$RES/mipmap-$d/ic_launcher.png"
magick "$ICON" -resize ${s}x${s} "$RES/mipmap-$d/ic_launcher_round.png"
magick "$MASK" -resize ${art}x${art} -background none \
-gravity center -extent ${fg}x${fg} "$RES/mipmap-$d/ic_launcher_foreground.png"
done
echo "icons generated"
+125
View File
@@ -0,0 +1,125 @@
#!/bin/bash
#
# Fetch the canonical GRIM build toolchains (code.gri.mw/DEV) into .toolchains/.
#
# These mirror exactly what upstream GRIM's CI uses, so Goblin cross-builds every
# platform from one Linux box the same way GRIM does — instead of relying on
# whatever NDK/zig/appimagetool happens to be installed on the machine.
#
# Idempotent: each tool is skipped if already present. Linux x86_64 host only
# (the box we build releases on). Writes .toolchains/env.sh with the exports the
# build scripts source; run nothing else by hand.
#
# Usage:
# scripts/toolchain.sh # core: ndk zig appimage (what desktop+android need)
# scripts/toolchain.sh sdk gradle # add the Android SDK + Gradle
# scripts/toolchain.sh osxcross # build the macOS cross-toolchain (heavy)
# scripts/toolchain.sh all # everything
#
set -euo pipefail
BASEDIR=$(cd "$(dirname "$0")/.." && pwd)
TC="${BASEDIR}/.toolchains"
DEV="https://code.gri.mw/DEV"
mkdir -p "${TC}"
dl() { echo "$(basename "$2")"; curl -fSL --retry 3 -o "$2" "$1"; }
# Pinned versions — bump here to track GRIM's DEV releases.
NDK_TAG="r29"; NDK_ARCHIVE="android-ndk-${NDK_TAG}-x86_64-linux-musl.tar.xz"; NDK_DIR="${TC}/android-ndk-${NDK_TAG}"
ZIG_VER="0.12.1"; ZIG_DIR="${TC}/zig"
AT_VER="1.9.1"; RT_TAG="20251108"
SDK_TAG="r36"; SDK_DIR="${TC}/android-sdk"
GRADLE_VER="8.13"; GRADLE_DIR="${TC}/gradle-${GRADLE_VER}"
SDK_VER="12.3"; OSX_DIR="${TC}/osxcross"
fetch_ndk() {
[ -e "${NDK_DIR}/source.properties" ] && { echo "ndk r29: present"; return; }
echo "ndk: fetching custom NDK ${NDK_TAG} (rebuilt LLVM, 16 KB-aligned)…"
dl "${DEV}/android-ndk-custom/releases/download/${NDK_TAG}/${NDK_ARCHIVE}" "${TC}/ndk.tar.xz"
tar -xJf "${TC}/ndk.tar.xz" -C "${TC}"; rm -f "${TC}/ndk.tar.xz"
}
fetch_zig() {
[ -x "${ZIG_DIR}/zig" ] && { echo "zig ${ZIG_VER}: present"; return; }
echo "zig: fetching ${ZIG_VER} (linker for cargo-zigbuild)…"
dl "${DEV}/zig/releases/download/${ZIG_VER}/zig-linux-x86_64-${ZIG_VER}.tar.xz" "${TC}/zig.tar.xz"
tar -xJf "${TC}/zig.tar.xz" -C "${TC}"; rm -f "${TC}/zig.tar.xz"
rm -rf "${ZIG_DIR}"; mv "${TC}/zig-linux-x86_64-${ZIG_VER}" "${ZIG_DIR}"
}
fetch_appimage() {
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && { echo "appimagetool ${AT_VER}: present"; return; }
echo "appimage: fetching appimagetool ${AT_VER} + type2 runtime…"
dl "${DEV}/appimagetool/releases/download/${AT_VER}/appimagetool-x86_64.AppImage" "${TC}/appimagetool"
dl "${DEV}/appimage-type2-runtime/releases/download/${RT_TAG}/runtime-x86_64" "${TC}/runtime-x86_64"
chmod +x "${TC}/appimagetool" "${TC}/runtime-x86_64"
}
# Assemble a minimal Android SDK (build-tools + platform + platform-tools) from
# the DEV mirror so gradle has an SDK without a system install.
fetch_sdk() {
[ -d "${SDK_DIR}/platform-tools" ] && { echo "android-sdk ${SDK_TAG}: present"; return; }
echo "android-sdk: fetching build-tools + platform-36 + platform-tools (${SDK_TAG})…"
local base="${DEV}/android-platform-tools/releases/download/${SDK_TAG}"
mkdir -p "${SDK_DIR}/build-tools" "${SDK_DIR}/platforms"
dl "${base}/build-tools_r36.1_linux.zip" "${TC}/bt.zip"
dl "${base}/platform-36_r02.zip" "${TC}/pf.zip"
dl "${base}/platform-tools_r36.0.0-linux.zip" "${TC}/pt.zip"
# build-tools zip unzips to android-NN/ → rename to the version dir gradle wants.
local tmp; tmp=$(mktemp -d)
unzip -q "${TC}/bt.zip" -d "${tmp}"; mv "${tmp}"/*/ "${SDK_DIR}/build-tools/36.1.0"
unzip -q "${TC}/pf.zip" -d "${tmp}"; mv "${tmp}"/*/ "${SDK_DIR}/platforms/android-36"
unzip -q "${TC}/pt.zip" -d "${SDK_DIR}"
rm -rf "${tmp}" "${TC}/bt.zip" "${TC}/pf.zip" "${TC}/pt.zip"
}
fetch_gradle() {
[ -x "${GRADLE_DIR}/bin/gradle" ] && { echo "gradle ${GRADLE_VER}: present"; return; }
echo "gradle: fetching ${GRADLE_VER}"
dl "${DEV}/gradle/releases/download/v${GRADLE_VER}/gradle-${GRADLE_VER}-bin.zip" "${TC}/gradle.zip"
unzip -q "${TC}/gradle.zip" -d "${TC}"; rm -f "${TC}/gradle.zip"
}
# osxcross: build the macOS cross-toolchain from source + the DEV macOS SDK.
# Heavy (compiles cctools/ld64); enables building macOS binaries off-Mac. CI also
# builds macOS natively, so this is the local/offline path — experimental.
fetch_osxcross() {
[ -x "${OSX_DIR}/target/bin/o64-clang" ] && { echo "osxcross: present"; return; }
command -v clang >/dev/null || { echo "osxcross: needs system clang/cmake — skipping"; return; }
echo "osxcross: cloning + building with macOS SDK ${SDK_VER} (slow)…"
[ -d "${OSX_DIR}/.git" ] || git clone --depth 1 "${DEV}/osxcross" "${OSX_DIR}"
dl "${DEV}/macosx-sdks/releases/download/${SDK_VER}/MacOSX${SDK_VER}.sdk.tar.xz" "${OSX_DIR}/tarballs/MacOSX${SDK_VER}.sdk.tar.xz"
( cd "${OSX_DIR}" && UNATTENDED=1 ./build.sh )
}
write_env() {
{
echo "# Auto-generated by scripts/toolchain.sh — source me for GRIM-canonical builds."
[ -e "${NDK_DIR}/source.properties" ] && { echo "export ANDROID_NDK_HOME=\"${NDK_DIR}\""; echo "export ANDROID_NDK_ROOT=\"${NDK_DIR}\""; }
[ -d "${SDK_DIR}/platform-tools" ] && echo "export ANDROID_HOME=\"${SDK_DIR}\""
local p="${TC}"
[ -x "${ZIG_DIR}/zig" ] && p="${ZIG_DIR}:${p}"
[ -x "${GRADLE_DIR}/bin/gradle" ] && p="${GRADLE_DIR}/bin:${p}"
[ -x "${OSX_DIR}/target/bin/o64-clang" ] && p="${OSX_DIR}/target/bin:${p}"
echo "export PATH=\"${p}:\$PATH\""
echo "export GOBLIN_APPIMAGETOOL=\"${TC}/appimagetool\""
echo "export GOBLIN_APPIMAGE_RUNTIME=\"${TC}/runtime-x86_64\""
} > "${TC}/env.sh"
}
tools=("$@"); [ ${#tools[@]} -eq 0 ] && tools=(ndk zig appimage)
[ "${tools[0]:-}" = "all" ] && tools=(ndk zig appimage sdk gradle osxcross)
for t in "${tools[@]}"; do
case "$t" in
ndk) fetch_ndk ;;
zig) fetch_zig ;;
appimage) fetch_appimage ;;
sdk) fetch_sdk ;;
gradle) fetch_gradle ;;
osxcross) fetch_osxcross ;;
*) echo "unknown tool: $t (ndk|zig|appimage|sdk|gradle|osxcross|all)" >&2; exit 1 ;;
esac
done
write_env
echo "toolchain ready → ${TC}/env.sh"
+5 -2
View File
@@ -70,6 +70,9 @@ else
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
@@ -88,12 +91,12 @@ cargo update -p grim
# Commit the changes
git add .
git commit -m "release: v$VERSION_NEXT"
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 tag "v$VERSION_NEXT" master
#git push origin master --follow-tags
-70
View File
@@ -1,70 +0,0 @@
@echo off
setlocal enabledelayedexpansion
:: Change directory to the script's location
cd /d "%~dp0"
:: Skip if Go not found.
where go >nul 2>nul
if %ERRORLEVEL% neq 0 (
echo Go could not be found
exit /b 0
)
set "go_os=%~1"
set "go_arch=%~2"
set "output_path=%~3"
echo Go build for os: %go_os%, arch: %go_arch%
:: Setup vars for Android.
if "%go_os%"=="android" (
:: Setup NDK root path env.
if "%ANDROID_NDK_HOME%"=="" (
:: Extract ndkVersion from build.gradle
:: Equivalent to: cat ../android/app/build.gradle | grep 'ndkVersion' | cut -d ' -f 2
for /f "tokens=2 delims='" %%a in ('findstr "ndkVersion" ..\android\app\build.gradle') do (
set "NDK_VERSION=%%a"
)
set "ANDROID_NDK_HOME=%ANDROID_HOME%\ndk\!NDK_VERSION!"
)
:: Setup NDK host path.
:: Since this is a Batch script, the host is Windows.
set "arch_host=windows-x86_64"
:: Setup NDK target arch.
if "%go_arch%"=="arm64" (
set "arch_bin_prefix=aarch64-linux-android"
) else if "%go_arch%"=="arm" (
set "arch_bin_prefix=armv7a-linux-androideabi"
) else (
set "arch_bin_prefix=x86_64-linux-android"
)
:: Build for current target.
set "CGO_ENABLED=1"
set "GOOS=%go_os%"
set "GOARCH=%go_arch%"
:: Define CC and CXX paths
set "CC=!ANDROID_NDK_HOME!\toolchains\llvm\prebuilt\!arch_host!\bin\!arch_bin_prefix!35-clang"
set "CXX=!ANDROID_NDK_HOME!\toolchains\llvm\prebuilt\!arch_host!\bin\!arch_bin_prefix!35-clang++"
go build -C "../tor/webtunnel" -ldflags="-s -w" -o "%output_path%" code.gri.mw/WEB/webtunnel/main/client
) else (
set "extra_flag="
if "%go_os%"=="windows" (
set "extra_flag=-H=windowsgui"
)
set "GOOS=%go_os%"
set "GOARCH=%go_arch%"
:: Build for non-android targets
go build -C "../tor/webtunnel" -ldflags="-s -w !extra_flag!" -o "%output_path%" code.gri.mw/WEB/webtunnel/main/client
)
endlocal
-51
View File
@@ -1,51 +0,0 @@
#!/bin/bash
cd "$(dirname "$0")"
# Skip if Go not found.
if ! command -v go >/dev/null 2>&1
then
echo "Go could not be found"
exit 0
fi
go_os=$1
go_arch=$2
echo "Go build for os: $go_os, arch: $go_arch"
# Setup vars for Android.
if [[ "$go_os" == "android" ]]; then
# Setup NDK root path env.
if [[ -z "$ANDROID_NDK_HOME" ]]; then
NDK_VERSION=$(cat ../android/app/build.gradle | grep 'ndkVersion' | cut -d \' -f 2)
ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
fi
# Setup NDK host path.
if [[ "$(uname)" == "Darwin" ]]; then
arch_host=darwin-x86_64
else
if [[ "$(uname -m)" == "aarch64" ]]; then
arch_host=linux-arm64
else
arch_host=linux-x86_64
fi
fi
# Setup NDK target arch.
if [[ "$go_arch" == "arm64" ]]; then
arch_bin_prefix=aarch64-linux-android
elif [[ "$go_arch" == "arm" ]]; then
arch_bin_prefix=armv7a-linux-androideabi
else
arch_bin_prefix=x86_64-linux-android
fi
# Build for current target.
CGO_ENABLED=1 GOOS=$1 GOARCH=$2 CC="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${arch_host}/bin/${arch_bin_prefix}35-clang" CXX="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${arch_path}/bin/${arch_bin_prefix}35-clang++" go build -C "../tor/webtunnel" -ldflags="-s -w" -o "$3" code.gri.mw/WEB/webtunnel/main/client
else
if [[ "$go_os" == "windows" ]]; then
extra_flag="-H=windowsgui"
fi
GOOS=$1 GOARCH=$2 go build -C "../tor/webtunnel" -ldflags="-s -w ${extra_flag}" -o "$3" code.gri.mw/WEB/webtunnel/main/client
fi
+404 -359
View File
@@ -12,418 +12,463 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::atomic::{AtomicBool, Ordering};
use egui::epaint::RectShape;
use egui::{Align, Context, CornerRadius, CursorIcon, LayerId, Layout, Modifiers, Order, ResizeDirection, Stroke, StrokeKind, UiBuilder, ViewportCommand};
use egui::{
Align, Context, CornerRadius, CursorIcon, LayerId, Layout, Modifiers, Order, ResizeDirection,
Stroke, StrokeKind, UiBuilder, ViewportCommand,
};
use lazy_static::lazy_static;
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::types::ContentContainer;
use crate::gui::views::{Content, KeyboardContent, Modal, TitlePanel, View};
use crate::gui::Colors;
use crate::AppConfig;
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> {
/// Handles platform-specific functionality.
pub platform: Platform,
/// Handles platform-specific functionality.
pub platform: Platform,
/// Main content.
content: Content,
/// Main content.
content: Content,
/// Last window resize direction.
resize_direction: Option<ResizeDirection>,
/// Flag to check if it's first draw.
first_draw: bool
/// 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,
first_draw: true
}
}
pub fn new(platform: Platform) -> Self {
Self {
platform,
content: Content::default(),
resize_direction: None,
first_draw: true,
status_bar_white: None,
}
}
/// Called of first content draw.
fn on_first_draw(&mut self, ctx: &Context) {
// Set platform context.
if View::is_desktop() {
self.platform.set_context(ctx);
}
// Setup visuals.
crate::setup_fonts(ctx);
crate::setup_visuals(ctx);
}
/// Called of first content draw.
fn on_first_draw(&mut self, ctx: &Context) {
// Set platform context.
if View::is_desktop() {
self.platform.set_context(ctx);
}
// Setup visuals.
crate::setup_fonts(ctx);
crate::setup_visuals(ctx);
}
/// Draw application content.
pub fn ui(&mut self, ctx: &Context) {
if self.first_draw {
self.on_first_draw(ctx);
self.first_draw = false;
}
/// Draw application content.
pub fn ui(&mut self, ctx: &Context) {
if self.first_draw {
self.on_first_draw(ctx);
self.first_draw = false;
}
// Handle Esc keyboard key event and platform Back button key event.
let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed);
if back_pressed || ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::Escape) ||
i.consume_key(Modifiers::NONE, egui::Key::BrowserBack)) {
// Pass event to content.
self.content.on_back(ctx, &self.platform);
if back_pressed {
BACK_BUTTON_PRESSED.store(false, Ordering::Relaxed);
}
// Request repaint to update previous content.
ctx.request_repaint();
}
// Keep the Android status-bar icons readable against the in-app theme
// (the app draws edge-to-edge over the status bar). Only on change.
let white_icons = crate::gui::theme::status_bar_white_icons();
if self.status_bar_white != Some(white_icons) {
self.platform.set_status_bar_white_icons(white_icons);
self.status_bar_white = Some(white_icons);
}
// Handle 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());
}
});
}
}
// 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();
}
// 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);
}
});
// 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());
}
});
}
}
// Check if desktop window was focused after requested attention.
if self.platform.user_attention_required() &&
ctx.input(|i| i.viewport().focused.unwrap_or(true)) {
self.platform.clear_user_attention();
}
// Show 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);
}
});
// 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();
}
}
// 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 custom desktop window frame content.
fn custom_frame_ui(&mut self, ui: &mut egui::Ui, is_fullscreen: bool) {
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);
// 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();
}
}
let mut content_rect = ui.max_rect();
if !is_fullscreen {
content_rect = content_rect.shrink(Content::WINDOW_FRAME_MARGIN);
}
// Draw window content.
ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| {
// Draw window title.
self.window_title_ui(ui, is_fullscreen);
ui.add_space(-1.0);
/// Draw custom desktop window frame content.
fn custom_frame_ui(&mut self, ui: &mut egui::Ui, is_fullscreen: bool) {
// Paint the window area inside the frame margin with the theme
// background first: surface gaps are otherwise transparent, which X11
// without a compositor renders as black (strip under the sidebar in
// light/yellow themes). The margin ring itself must STAY transparent —
// painting it gives the window a visible border under a compositor.
let fill_rect = if is_fullscreen {
ui.max_rect()
} else {
ui.max_rect().shrink(Content::WINDOW_FRAME_MARGIN)
};
let fill_rounding = if is_fullscreen {
CornerRadius::ZERO
} else {
CornerRadius {
nw: 8,
ne: 8,
sw: 0,
se: 0,
}
};
ui.painter()
.rect_filled(fill_rect, fill_rounding, Colors::fill());
let content_bg_rect = {
let mut r = ui.max_rect();
if !is_fullscreen {
r = r.shrink(Content::WINDOW_FRAME_MARGIN);
}
r.min.y += Content::WINDOW_TITLE_HEIGHT + TitlePanel::HEIGHT;
r
};
let content_bg = RectShape::new(
content_bg_rect,
CornerRadius::ZERO,
Colors::fill_lite(),
View::default_stroke(),
StrokeKind::Outside,
);
// Draw content background.
ui.painter().add(content_bg);
// Draw title panel background.
Self::title_panel_bg(ui, true);
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 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);
});
// Draw title panel background.
Self::title_panel_bg(ui, true);
// 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 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);
});
/// 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);
}
// Setup resize areas.
if !is_fullscreen {
self.resize_area_ui(ui, ResizeDirection::North);
self.resize_area_ui(ui, ResizeDirection::East);
self.resize_area_ui(ui, ResizeDirection::South);
self.resize_area_ui(ui, ResizeDirection::West);
self.resize_area_ui(ui, ResizeDirection::NorthWest);
self.resize_area_ui(ui, ResizeDirection::NorthEast);
self.resize_area_ui(ui, ResizeDirection::SouthEast);
self.resize_area_ui(ui, ResizeDirection::SouthWest);
}
}
/// Draw 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
};
/// Draw title panel background.
fn title_panel_bg(ui: &mut egui::Ui, window_title: bool) {
let title_rect = {
let mut rect = ui.max_rect();
if window_title {
rect.min.y += Content::WINDOW_TITLE_HEIGHT - 0.5;
}
rect.max.y = rect.min.y + View::get_top_inset() + TitlePanel::HEIGHT;
rect
};
let title_bg = RectShape::filled(title_rect, CornerRadius::ZERO, Colors::yellow());
ui.painter().add(title_bg);
}
let 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);
/// Draw custom window title content.
fn window_title_ui(&self, ui: &mut egui::Ui, is_fullscreen: bool) {
let title_rect = {
let mut rect = ui.max_rect();
rect.max.y = rect.min.y + Content::WINDOW_TITLE_HEIGHT;
rect
};
let painter = ui.painter();
let title_bg_rect = {
let mut r = title_rect.clone();
r.max.y += TitlePanel::HEIGHT - 1.0;
r
};
let is_mac = egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac;
let window_title_bg = RectShape::new(
title_bg_rect,
if is_fullscreen || is_mac {
CornerRadius::ZERO
} else {
CornerRadius {
nw: 8.0 as u8,
ne: 8.0 as u8,
sw: 0.0 as u8,
se: 0.0 as u8,
}
},
Colors::yellow_dark(),
Stroke::new(1.0, Colors::STROKE),
StrokeKind::Outside,
);
// Draw title background.
ui.painter().add(window_title_bg);
let 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);
}
let painter = ui.painter();
// Paint the title.
let title_text = format!("Grim {}", crate::VERSION);
painter.text(
title_rect.center(),
egui::Align2::CENTER_CENTER,
title_text,
egui::FontId::proportional(15.0),
Colors::title(true),
);
let interact_rect = {
let mut rect = title_rect.clone();
rect.max.x -= 128.0;
rect.min.x += 85.0;
if !is_fullscreen {
rect.min.y += Content::WINDOW_FRAME_MARGIN;
}
rect
};
let title_resp = ui.interact(
interact_rect,
egui::Id::new("window_title"),
egui::Sense::drag(),
);
// Interact with the window title (drag to move window):
if !is_fullscreen && title_resp.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
}
ui.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();
}
});
// Paint the title.
let title_text = format!("Goblin ツ · Build {}", crate::BUILD);
painter.text(
title_rect.center(),
egui::Align2::CENTER_CENTER,
title_text,
egui::FontId::proportional(15.0),
Colors::title(true),
);
// Draw fullscreen button.
let fullscreen_icon = if is_fullscreen {
ARROWS_IN
} else {
ARROWS_OUT
};
View::title_button_small(ui, fullscreen_icon, |ui| {
ui.ctx().send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen));
});
ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Draw button to close window.
View::title_button_small(ui, X, |_| {
if Modal::opened().is_none() || Modal::opened_closeable() {
Content::show_exit_modal();
}
});
// Draw button to minimize window.
View::title_button_small(ui, CARET_DOWN, |ui| {
ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true));
});
// Draw fullscreen button.
let fullscreen_icon = if is_fullscreen { ARROWS_IN } else { ARROWS_OUT };
View::title_button_small(ui, fullscreen_icon, |ui| {
ui.ctx()
.send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen));
});
// Draw application icon.
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| {
// Draw button to minimize window.
let use_dark = AppConfig::dark_theme().unwrap_or(false);
let theme_icon = if use_dark {
SUN
} else {
MOON
};
View::title_button_small(ui, theme_icon, |ui| {
AppConfig::set_dark_theme(!use_dark);
crate::setup_visuals(ui.ctx());
});
});
});
});
}
// Draw button to minimize window.
View::title_button_small(ui, CARET_DOWN, |ui| {
ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true));
});
/// Setup window resize area.
fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) {
let mut rect = ui.max_rect();
// Draw application icon.
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(
layout_size,
Layout::left_to_right(Align::Center),
|ui| {
// Draw button to minimize window.
let use_dark = AppConfig::dark_theme().unwrap_or(false);
let theme_icon = if use_dark { SUN } else { MOON };
View::title_button_small(ui, theme_icon, |ui| {
AppConfig::set_dark_theme(!use_dark);
crate::setup_visuals(ui.ctx());
});
},
);
});
});
}
// Setup area id, cursor and area rect based on direction.
let (id, cursor, rect) = match direction {
ResizeDirection::North => ("n", CursorIcon::ResizeNorth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::East => ("e", CursorIcon::ResizeEast, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::South => ("s", CursorIcon::ResizeSouth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::West => ("w", CursorIcon::ResizeWest, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::NorthWest => ("nw", CursorIcon::ResizeNorthWest, {
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.max.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::NorthEast => ("ne", CursorIcon::ResizeNorthEast, {
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::SouthEast => ("se", CursorIcon::ResizeSouthEast, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::SouthWest => ("sw", CursorIcon::ResizeSouthWest, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
};
/// Setup window resize area.
fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) {
let mut rect = ui.max_rect();
// Setup resize area.
let id = egui::Id::new("window_resize").with(id);
let sense = egui::Sense::drag();
let area_resp = ui.interact(rect, id, sense).on_hover_cursor(cursor);
if area_resp.dragged() {
if self.resize_direction.is_none() {
self.resize_direction = Some(direction.clone());
ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction));
}
}
if area_resp.drag_stopped() {
self.resize_direction = None;
}
}
// Setup area id, cursor and area rect based on direction.
let (id, cursor, rect) = match direction {
ResizeDirection::North => ("n", CursorIcon::ResizeNorth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::East => ("e", CursorIcon::ResizeEast, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::South => ("s", CursorIcon::ResizeSouth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::West => ("w", CursorIcon::ResizeWest, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::NorthWest => ("nw", CursorIcon::ResizeNorthWest, {
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.max.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::NorthEast => ("ne", CursorIcon::ResizeNorthEast, {
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::SouthEast => ("se", CursorIcon::ResizeSouthEast, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
ResizeDirection::SouthWest => ("sw", CursorIcon::ResizeSouthWest, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN * 2.0;
rect
}),
};
// Setup resize area.
let id = egui::Id::new("window_resize").with(id);
let sense = egui::Sense::drag();
let area_resp = ui.interact(rect, id, sense).on_hover_cursor(cursor);
if area_resp.dragged() {
if self.resize_direction.is_none() {
self.resize_direction = Some(direction.clone());
ui.ctx()
.send_viewport_cmd(ViewportCommand::BeginResize(direction));
}
}
if area_resp.drag_stopped() {
self.resize_direction = None;
}
}
}
/// To draw with egui`s eframe (for wgpu, glow backends and wasm target).
impl<Platform: PlatformCallbacks> eframe::App for App<Platform> {
fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) {
ctx.plugin_or_default::<egui_async::EguiAsyncPlugin>();
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] {
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()
}
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);
}
+119 -218
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,255 +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 GOLD_DARK: Color32 = Color32::from_rgb(240, 203, 1);
const INK: Color32 = Color32::from_rgb(0x0E, 0x0E, 0x0C);
const PAPER: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xF7);
const YELLOW: Color32 = Color32::from_rgb(254, 241, 2);
const YELLOW_DARK: Color32 = Color32::from_rgb(239, 229, 3);
const GREEN: Color32 = Color32::from_rgb(0, 0x64, 0);
const GREEN_DARK: Color32 = Color32::from_rgb(0, (0x64 as f32 * 1.3 + 0.5) as u8, 0);
const RED: Color32 = Color32::from_rgb(0x8B, 0, 0);
const RED_DARK: Color32 = Color32::from_rgb((0x8B as f32 * 1.3 + 0.5) as u8, 50, 30);
const BLUE: Color32 = Color32::from_rgb(0, 0x66, 0xE4);
const BLUE_DARK: Color32 =
Color32::from_rgb(0, (0x66 as f32 * 1.3 + 0.5) as u8, (0xE4 as f32 * 1.3 + 0.5) as u8);
const FILL: Color32 = Color32::from_gray(244);
const FILL_DARK: Color32 = Color32::from_gray(26);
const FILL_DEEP_DARK: Color32 = Color32::from_gray(32);
const FILL_LITE: Color32 = Color32::from_gray(249);
const FILL_LITE_DARK: Color32 = Color32::from_gray(21);
const TEXT: Color32 = Color32::from_gray(80);
const TEXT_DARK: Color32 = Color32::from_gray(185);
const CHECKBOX: Color32 = Color32::from_gray(100);
const CHECKBOX_DARK: Color32 = Color32::from_gray(175);
const TEXT_BUTTON: Color32 = Color32::from_gray(70);
const TEXT_BUTTON_DARK: Color32 = Color32::from_gray(195);
const TITLE: Color32 = Color32::from_gray(60);
const TITLE_DARK: Color32 = Color32::from_gray(205);
const GRAY: Color32 = Color32::from_gray(120);
const GRAY_DARK: Color32 = Color32::from_gray(145);
const STROKE_DARK: Color32 = Color32::from_gray(50);
const INACTIVE_TEXT: Color32 = Color32::from_gray(150);
const INACTIVE_TEXT_DARK: Color32 = Color32::from_gray(115);
const ITEM_BUTTON: Color32 = Color32::from_gray(90);
const ITEM_BUTTON_DARK: Color32 = Color32::from_gray(175);
const ITEM_STROKE: Color32 = Color32::from_gray(220);
const ITEM_STROKE_DARK: Color32 = Color32::from_gray(40);
const ITEM_HOVER: Color32 = Color32::from_gray(205);
const ITEM_HOVER_DARK: Color32 = Color32::from_gray(48);
/// Check if dark theme should be used.
fn use_dark() -> bool {
AppConfig::dark_theme().unwrap_or(false)
fn dark_base() -> bool {
theme::tokens().dark_base
}
impl Colors {
pub const FILL_DEEP: Color32 = Color32::from_gray(238);
pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0);
pub const STROKE: Color32 = Color32::from_gray(200);
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 gold_dark() -> Color32 {
if use_dark() {
GOLD_DARK.gamma_multiply(0.9)
} else {
GOLD_DARK
}
}
pub fn gold_dark() -> Color32 {
theme::tokens().accent_dark
}
pub fn yellow() -> Color32 {
YELLOW
}
pub fn yellow() -> Color32 {
theme::tokens().accent
}
pub fn yellow_dark() -> Color32 {
YELLOW_DARK
}
pub fn yellow_dark() -> Color32 {
theme::tokens().accent_dark
}
pub fn green() -> Color32 {
if use_dark() {
GREEN_DARK
} else {
GREEN
}
}
/// Ink color to draw on top of accent fills.
pub fn accent_ink() -> Color32 {
theme::tokens().accent_ink
}
pub fn red() -> Color32 {
if use_dark() {
RED_DARK
} else {
RED
}
}
pub fn green() -> Color32 {
theme::tokens().pos
}
pub fn blue() -> Color32 {
if use_dark() {
BLUE_DARK
} else {
BLUE
}
}
pub fn red() -> Color32 {
theme::tokens().neg
}
pub fn fill() -> Color32 {
if use_dark() {
FILL_DARK
} else {
FILL
}
}
pub fn blue() -> Color32 {
if dark_base() {
Color32::from_rgb(0x7B, 0xA7, 0xFF)
} else {
Color32::from_rgb(0x0E, 0x62, 0xD0)
}
}
pub fn fill_deep() -> Color32 {
if use_dark() {
FILL_DEEP_DARK
} else {
Self::FILL_DEEP
}
}
pub fn fill() -> Color32 {
theme::tokens().bg
}
pub fn fill_lite() -> Color32 {
if use_dark() {
FILL_LITE_DARK
} else {
FILL_LITE
}
}
pub fn fill_deep() -> Color32 {
theme::tokens().surface2
}
pub fn checkbox() -> Color32 {
if use_dark() {
CHECKBOX_DARK
} else {
CHECKBOX
}
}
pub fn fill_lite() -> Color32 {
theme::tokens().surface
}
pub fn text(always_light: bool) -> Color32 {
if use_dark() && !always_light {
TEXT_DARK
} else {
TEXT
}
}
pub fn checkbox() -> Color32 {
theme::tokens().text_dim
}
pub fn text_button() -> Color32 {
if use_dark() {
TEXT_BUTTON_DARK
} else {
TEXT_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 title(always_light: bool) -> Color32 {
if use_dark() && !always_light {
TITLE_DARK
} else {
TITLE
}
}
pub fn text_button() -> Color32 {
theme::tokens().text
}
pub fn gray() -> Color32 {
if use_dark() {
GRAY_DARK
} else {
GRAY
}
}
pub fn title(always_light: bool) -> Color32 {
if always_light {
INK
} else {
theme::tokens().text
}
}
pub fn stroke() -> Color32 {
if use_dark() {
STROKE_DARK
} else {
Self::STROKE
}
}
pub fn gray() -> Color32 {
theme::tokens().text_mute
}
pub fn inactive_text() -> Color32 {
if use_dark() {
INACTIVE_TEXT_DARK
} else {
INACTIVE_TEXT
}
}
pub fn stroke() -> Color32 {
theme::tokens().line
}
pub fn item_button_text() -> Color32 {
if use_dark() {
ITEM_BUTTON_DARK
} else {
ITEM_BUTTON
}
}
pub fn inactive_text() -> Color32 {
theme::tokens().text_mute
}
pub fn item_stroke() -> Color32 {
if use_dark() {
ITEM_STROKE_DARK
} else {
ITEM_STROKE
}
}
pub fn item_button_text() -> Color32 {
theme::tokens().text_dim
}
pub fn item_hover() -> Color32 {
if use_dark() {
ITEM_HOVER_DARK
} else {
ITEM_HOVER
}
}
}
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;
+203 -171
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,210 +30,242 @@ use crate::gui::platform::PlatformCallbacks;
/// Android platform implementation.
#[derive(Clone)]
pub struct Android {
/// Android related state.
android_app: AndroidApp,
/// Android related state.
android_app: AndroidApp,
/// Context to repaint content and handle viewport commands.
ctx: Arc<RwLock<Option<egui::Context>>>,
/// 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,
ctx: Arc::new(RwLock::new(None)),
}
}
/// Create new Android platform instance from provided [`AndroidApp`].
pub fn new(app: AndroidApp) -> Self {
Self {
android_app: app,
ctx: Arc::new(RwLock::new(None)),
}
}
/// Call Android Activity method with JNI.
pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option<jni::sys::jvalue> {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let activity = unsafe {
JObject::from_raw(self.android_app.activity_as_ptr() as jni::sys::jobject)
};
if let Ok(result) = env.call_method(activity, name, s, a) {
return Some(result.as_jni().clone());
}
None
}
/// Call Android Activity method with JNI.
pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option<jni::sys::jvalue> {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let activity =
unsafe { JObject::from_raw(self.android_app.activity_as_ptr() as jni::sys::jobject) };
if let Ok(result) = env.call_method(activity, name, s, a) {
return Some(result.as_jni().clone());
}
None
}
}
impl PlatformCallbacks for Android {
fn set_context(&mut self, ctx: &egui::Context) {
let mut w_ctx = self.ctx.write();
*w_ctx = Some(ctx.clone());
}
fn set_context(&mut self, ctx: &egui::Context) {
let mut w_ctx = self.ctx.write();
*w_ctx = Some(ctx.clone());
}
fn exit(&self) {
let _ = self.call_java_method("exit", "()V", &[]);
}
fn exit(&self) {
let _ = self.call_java_method("exit", "()V", &[]);
}
fn copy_string_to_buffer(&self, data: String) {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(data).unwrap();
let _ = self.call_java_method("copyText",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))]);
}
fn copy_string_to_buffer(&self, data: String) {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(data).unwrap();
let _ = self.call_java_method(
"copyText",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))],
);
}
fn get_string_from_buffer(&self) -> String {
let result = self.call_java_method("pasteText", "()Ljava/lang/String;", &[]).unwrap();
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let j_object: jni::sys::jobject = unsafe { result.l };
let paste_data: String = unsafe {
env.get_string(JString::from(JObject::from_raw(j_object)).as_ref()).unwrap().into()
};
paste_data
}
fn get_string_from_buffer(&self) -> String {
let result = self
.call_java_method("pasteText", "()Ljava/lang/String;", &[])
.unwrap();
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let j_object: jni::sys::jobject = unsafe { result.l };
let paste_data: String = unsafe {
env.get_string(JString::from(JObject::from_raw(j_object)).as_ref())
.unwrap()
.into()
};
paste_data
}
fn start_camera(&self) {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
// Start camera.
let _ = self.call_java_method("startCamera", "()V", &[]);
}
fn start_camera(&self) {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
// Start camera.
let _ = self.call_java_method("startCamera", "()V", &[]);
}
fn stop_camera(&self) {
// Stop camera.
let _ = self.call_java_method("stopCamera", "()V", &[]);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
}
fn stop_camera(&self) {
// Stop camera.
let _ = self.call_java_method("stopCamera", "()V", &[]);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
}
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
let r_image = LAST_CAMERA_IMAGE.read();
if r_image.is_some() {
return Some(r_image.clone().unwrap());
}
None
}
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 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 can_switch_camera(&self) -> bool {
if let Some(res) = self.call_java_method("camerasAmount", "()I", &[]) {
let amount = unsafe { res.i };
return amount > 1;
}
false
}
fn switch_camera(&self) {
let _ = self.call_java_method("switchCamera", "()V", &[]);
}
fn switch_camera(&self) {
let _ = self.call_java_method("switchCamera", "()V", &[]);
}
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 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 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 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_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 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 request_user_attention(&self) {}
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 user_attention_required(&self) -> bool {
false
}
fn request_user_attention(&self) {}
fn clear_user_attention(&self) {}
fn user_attention_required(&self) -> bool {
false
}
fn clear_user_attention(&self) {}
fn set_status_bar_white_icons(&self, white: bool) {
self.call_java_method(
"setStatusBarWhiteIcons",
"(Z)V",
&[JValue::Bool(white as u8)],
);
}
fn vibrate_error(&self) {
let _ = self.call_java_method("vibrateError", "()V", &[]);
}
}
lazy_static! {
/// Last image data from camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
/// Picked file path.
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
/// Last image data from camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
/// Picked file path.
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
}
/// Callback from Java code with last entered character from soft keyboard.
#[allow(non_snake_case)]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage(
env: JNIEnv,
_class: JObject,
buff: jni::sys::jbyteArray,
rotation: jni::sys::jint,
env: JNIEnv,
_class: JObject,
buff: jni::sys::jbyteArray,
rotation: jni::sys::jint,
) {
let arr = unsafe { JByteArray::from_raw(buff) };
let image : Vec<u8> = env.convert_byte_array(arr).unwrap();
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((image, rotation as u32));
let arr = unsafe { JByteArray::from_raw(buff) };
let image: Vec<u8> = env.convert_byte_array(arr).unwrap();
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((image, rotation as u32));
}
/// Callback from Java code with picked file path.
#[allow(non_snake_case)]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn Java_mw_gri_android_MainActivity_onFilePick(
_env: JNIEnv,
_class: JObject,
char: jni::sys::jstring
_env: JNIEnv,
_class: JObject,
char: jni::sys::jstring,
) {
use std::ops::Add;
unsafe {
let j_obj = JString::from_raw(char);
let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap();
match j_str.to_str() {
Ok(str) => {
let mut w_path = PICKED_FILE_PATH.write();
*w_path = Some(w_path.clone().unwrap_or("".to_string()).add(str));
}
Err(_) => {}
}
}
}
use std::ops::Add;
unsafe {
let j_obj = JString::from_raw(char);
let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap();
match j_str.to_str() {
Ok(str) => {
let mut w_path = PICKED_FILE_PATH.write();
*w_path = Some(w_path.clone().unwrap_or("".to_string()).add(str));
}
Err(_) => {}
}
}
}
+304 -263
View File
@@ -12,309 +12,350 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::{UserAttentionType, ViewportCommand, WindowLevel};
use lazy_static::lazy_static;
use parking_lot::RwLock;
use rfd::FileDialog;
use std::fs::File;
use std::io::Write;
use std::thread;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use parking_lot::RwLock;
use lazy_static::lazy_static;
use egui::{UserAttentionType, ViewportCommand, WindowLevel};
use rfd::FileDialog;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use crate::gui::platform::PlatformCallbacks;
/// Desktop platform related actions.
#[derive(Clone)]
pub struct Desktop {
/// Context to repaint content and handle viewport commands.
ctx: Arc<RwLock<Option<egui::Context>>>,
/// Context to repaint content and handle viewport commands.
ctx: Arc<RwLock<Option<egui::Context>>>,
/// Cameras amount.
cameras_amount: Arc<AtomicUsize>,
/// Camera index.
camera_index: Arc<AtomicUsize>,
/// Flag to check if camera stop is needed.
stop_camera: Arc<AtomicBool>,
/// Cameras amount.
cameras_amount: Arc<AtomicUsize>,
/// Camera index.
camera_index: Arc<AtomicUsize>,
/// Flag to check if camera stop is needed.
stop_camera: Arc<AtomicBool>,
/// Flag to check if attention required after window focusing.
attention_required: Arc<AtomicBool>,
/// Flag to check if attention required after window focusing.
attention_required: Arc<AtomicBool>,
}
impl Desktop {
pub fn new() -> Self {
Self {
ctx: Arc::new(RwLock::new(None)),
cameras_amount: Arc::new(AtomicUsize::new(0)),
camera_index: Arc::new(AtomicUsize::new(0)),
stop_camera: Arc::new(AtomicBool::new(false)),
attention_required: Arc::new(AtomicBool::new(false)),
}
}
pub fn new() -> Self {
Self {
ctx: Arc::new(RwLock::new(None)),
cameras_amount: Arc::new(AtomicUsize::new(0)),
camera_index: Arc::new(AtomicUsize::new(0)),
stop_camera: Arc::new(AtomicBool::new(false)),
attention_required: Arc::new(AtomicBool::new(false)),
}
}
// #[allow(dead_code)]
#[cfg(not(target_os = "macos"))]
fn start_camera_capture(cameras_amount: Arc<AtomicUsize>,
camera_index: Arc<AtomicUsize>,
stop_camera: Arc<AtomicBool>) {
use nokhwa::Camera;
use nokhwa::pixel_format::RgbFormat;
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
use nokhwa::utils::ApiBackend;
// #[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 devices = nokhwa::query(ApiBackend::Auto).unwrap();
cameras_amount.store(devices.len(), Ordering::Relaxed);
let index = camera_index.load(Ordering::Relaxed);
if devices.is_empty() || index >= devices.len() {
return;
}
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;
}
thread::spawn(move || {
let index = CameraIndex::Index(camera_index.load(Ordering::Relaxed) as u32);
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() {
// 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();
};
}
});
}
// 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();
};
}
});
}
#[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::nokhwa_initialize;
use nokhwa::pixel_format::RgbFormat;
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
use nokhwa::utils::ApiBackend;
use nokhwa::query;
use nokhwa::CallbackCamera;
#[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};
// Ask permission to open camera.
nokhwa_initialize(|_| {});
// 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;
}
}
}
}
});
}
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 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 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 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 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);
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);
}
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 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 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 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 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 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_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_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 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 picked_file(&self) -> Option<String> {
None
}
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 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 picked_file(&self) -> Option<String> {
None
}
fn user_attention_required(&self) -> bool {
self.attention_required.load(Ordering::Relaxed)
}
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 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);
}
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));
}
+36 -17
View File
@@ -22,20 +22,39 @@ pub mod platform;
pub mod platform;
pub trait PlatformCallbacks {
fn set_context(&mut self, ctx: &egui::Context);
fn exit(&self);
fn copy_string_to_buffer(&self, data: String);
fn get_string_from_buffer(&self) -> String;
fn start_camera(&self);
fn stop_camera(&self);
fn camera_image(&self) -> Option<(Vec<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 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);
}
fn set_context(&mut self, ctx: &egui::Context);
fn exit(&self);
fn copy_string_to_buffer(&self, data: String);
fn get_string_from_buffer(&self) -> String;
fn start_camera(&self);
fn stop_camera(&self);
fn camera_image(&self) -> Option<(Vec<u8>, u32)>;
fn can_switch_camera(&self) -> bool;
fn switch_camera(&self);
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
/// Share plain text via the platform's native share sheet (e.g. a payment
/// link). Defaults to copying to the clipboard on platforms without a share
/// sheet (desktop).
fn share_text(&self, text: String) {
self.copy_string_to_buffer(text);
}
fn pick_file(&self) -> Option<String>;
/// Native picker filtered to picture files; defaults to the plain picker
/// on platforms without filter support (magic-byte sniffing protects).
fn pick_image_file(&self) -> Option<String> {
self.pick_file()
}
fn pick_folder(&self) -> Option<String>;
fn picked_file(&self) -> Option<String>;
fn request_user_attention(&self);
fn user_attention_required(&self) -> bool;
fn clear_user_attention(&self);
/// Set the status-bar icon color to contrast the current theme. `white` =
/// light icons (for a dark background). No-op off Android.
fn set_status_bar_white_icons(&self, _white: bool) {}
/// Play a short "error" haptic (e.g. a rejected over-balance payment).
/// No-op off Android.
fn vibrate_error(&self) {}
}
+350
View File
@@ -0,0 +1,350 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Goblin design tokens: three themes (light/dark/yellow) and density scales,
//! taken verbatim from the Goblin design handoff.
use std::cell::Cell;
use egui::Color32;
use crate::AppConfig;
/// Available color themes.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ThemeKind {
Light,
Dark,
Yellow,
}
impl ThemeKind {
pub fn id(&self) -> &'static str {
match self {
ThemeKind::Light => "light",
ThemeKind::Dark => "dark",
ThemeKind::Yellow => "yellow",
}
}
pub fn from_id(id: &str) -> Option<ThemeKind> {
match id {
"light" => Some(ThemeKind::Light),
"dark" => Some(ThemeKind::Dark),
"yellow" => Some(ThemeKind::Yellow),
_ => None,
}
}
}
/// Color tokens for a theme.
pub struct ThemeTokens {
pub bg: Color32,
pub surface: Color32,
pub surface2: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_mute: Color32,
/// Text on surface/surface2 fills. Matches `text` in light/dark, but the
/// yellow theme has dark surfaces on a bright bg, so on-surface text must
/// be light there while `text` stays dark for the bg.
pub surface_text: Color32,
pub surface_text_dim: Color32,
pub surface_text_mute: Color32,
pub line: Color32,
pub accent: Color32,
pub accent_dark: Color32,
pub accent_ink: Color32,
pub pos: Color32,
pub neg: Color32,
pub chip: Color32,
pub hover: Color32,
/// Avatar background palette (initial ink picked by luminance).
pub avatar_pairs: [(Color32, Color32); 8],
/// Whether egui widgets should use the dark base style.
pub dark_base: bool,
}
/// Avatar (background, ink) pairs shared by all themes — bright pastels
/// carry dark ink, saturated darks carry light ink.
const AVATAR_PAIRS: [(Color32, Color32); 8] = [
(
Color32::from_rgb(0xFF, 0xD6, 0x0A),
Color32::from_rgb(0x0E, 0x0E, 0x0C),
), // accent yellow / ink
(
Color32::from_rgb(0xFF, 0x8E, 0x3C),
Color32::from_rgb(0x26, 0x10, 0x02),
), // orange / deep brown
(
Color32::from_rgb(0x5B, 0xD2, 0x7A),
Color32::from_rgb(0x0E, 0x0E, 0x0C),
), // light green / black
(
Color32::from_rgb(0x7B, 0xA7, 0xFF),
Color32::from_rgb(0x0B, 0x14, 0x33),
), // periwinkle / navy ink
(
Color32::from_rgb(0x6B, 0x4F, 0xC8),
Color32::from_rgb(0xF4, 0xF0, 0xFF),
), // purple / light text
(
Color32::from_rgb(0xE1, 0x74, 0xD0),
Color32::from_rgb(0x32, 0x07, 0x2B),
), // pink / dark plum
(
Color32::from_rgb(0x1F, 0x7A, 0x5C),
Color32::from_rgb(0xE7, 0xFF, 0xF4),
), // deep teal / light mint
(
Color32::from_rgb(0xA0, 0xE6, 0x6E),
Color32::from_rgb(0x14, 0x22, 0x0A),
), // lime / dark moss
];
pub const LIGHT: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface: Color32::from_rgb(0xFF, 0xFF, 0xFF),
surface2: Color32::from_rgb(0xF2, 0xF1, 0xEC),
text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63),
text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B),
surface_text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface_text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63),
surface_text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B),
// rgba(14,14,12,0.08) premultiplied.
line: Color32::from_rgba_premultiplied(1, 1, 1, 20),
accent: Color32::from_rgb(0xFF, 0xD6, 0x0A),
accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00),
accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C),
pos: Color32::from_rgb(0x0E, 0x7C, 0x3A),
neg: Color32::from_rgb(0xB0, 0x48, 0x1E),
chip: Color32::from_rgb(0xF2, 0xF1, 0xEC),
hover: Color32::from_rgb(0xE9, 0xE7, 0xE0),
avatar_pairs: AVATAR_PAIRS,
dark_base: false,
};
pub const DARK: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface: Color32::from_rgb(0x1A, 0x1A, 0x17),
surface2: Color32::from_rgb(0x24, 0x24, 0x20),
text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
// rgba(255,255,255,0.08) premultiplied.
line: Color32::from_rgba_premultiplied(20, 20, 20, 20),
accent: Color32::from_rgb(0xFF, 0xD6, 0x0A),
accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00),
accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C),
pos: Color32::from_rgb(0x5B, 0xD2, 0x7A),
neg: Color32::from_rgb(0xFF, 0x8B, 0x5E),
chip: Color32::from_rgb(0x24, 0x24, 0x20),
hover: Color32::from_rgb(0x2E, 0x2E, 0x29),
avatar_pairs: AVATAR_PAIRS,
dark_base: true,
};
pub const YELLOW: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0xFF, 0xD6, 0x0A),
surface: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface2: Color32::from_rgb(0x1A, 0x1A, 0x17),
text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
text_dim: Color32::from_rgb(0x3A, 0x3A, 0x36),
// Muted on-bg tier darkened for the bright yellow bg: #6B6A63 was only
// 3.85:1 (sub-WCAG-AA); #55534A is 5.5:1 and still the faintest tier.
text_mute: Color32::from_rgb(0x55, 0x53, 0x4A),
surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
// rgba(14,14,12,0.18) premultiplied.
line: Color32::from_rgba_premultiplied(2, 2, 2, 46),
accent: Color32::from_rgb(0x0E, 0x0E, 0x0C),
accent_dark: Color32::from_rgb(0x24, 0x24, 0x20),
accent_ink: Color32::from_rgb(0xFF, 0xD6, 0x0A),
pos: Color32::from_rgb(0x0E, 0x7C, 0x3A),
neg: Color32::from_rgb(0x9E, 0x2E, 0x0E),
chip: Color32::from_rgba_premultiplied(2, 2, 2, 20),
hover: Color32::from_rgb(0xEF, 0xC8, 0x00),
avatar_pairs: AVATAR_PAIRS,
dark_base: false,
};
thread_local! {
/// Per-frame theme override (see [`scoped`]). egui renders on one thread, so
/// a thread-local Cell scopes a different theme to a single surface without
/// touching the persisted app config.
static OVERRIDE: Cell<Option<ThemeKind>> = const { Cell::new(None) };
}
/// RAII guard that forces [`kind`]/[`tokens`] to a specific theme for its
/// lifetime, restoring the previous value on drop (panic-safe). Used to paint
/// one surface — the Pay tab — in the yellow theme regardless of the user's
/// chosen theme, à la Cash App's brand-colored pay screen.
#[must_use = "the override only lasts while the guard is alive"]
pub struct ScopedTheme(Option<ThemeKind>);
impl Drop for ScopedTheme {
fn drop(&mut self) {
OVERRIDE.with(|c| c.set(self.0.take()));
}
}
/// Override the active theme until the returned guard drops.
pub fn scoped(kind: ThemeKind) -> ScopedTheme {
ScopedTheme(OVERRIDE.with(|c| c.replace(Some(kind))))
}
/// Current theme kind: a scoped override if one is active, else app config
/// (dark is the product default).
pub fn kind() -> ThemeKind {
OVERRIDE.with(|c| c.get()).unwrap_or_else(AppConfig::theme)
}
/// Current theme tokens.
pub fn tokens() -> &'static ThemeTokens {
match kind() {
ThemeKind::Light => &LIGHT,
ThemeKind::Dark => &DARK,
ThemeKind::Yellow => &YELLOW,
}
}
/// Whether the status bar should use light (white) icons: true on the dark
/// theme (dark top), false on the light/yellow themes (bright top).
pub fn status_bar_white_icons() -> bool {
tokens().dark_base
}
/// Density scales from the design handoff.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DensityKind {
Compact,
Regular,
Comfy,
}
impl DensityKind {
pub fn id(&self) -> &'static str {
match self {
DensityKind::Compact => "compact",
DensityKind::Regular => "regular",
DensityKind::Comfy => "comfy",
}
}
pub fn from_id(id: &str) -> Option<DensityKind> {
match id {
"compact" => Some(DensityKind::Compact),
"regular" => Some(DensityKind::Regular),
"comfy" => Some(DensityKind::Comfy),
_ => None,
}
}
}
/// Spacing tokens for a density.
#[derive(Clone, Copy)]
pub struct DensityTokens {
pub pad: f32,
pub gap: f32,
pub radius: f32,
pub row: f32,
}
pub const COMPACT: DensityTokens = DensityTokens {
pad: 12.0,
gap: 10.0,
radius: 10.0,
row: 56.0,
};
pub const REGULAR: DensityTokens = DensityTokens {
pad: 16.0,
gap: 14.0,
radius: 16.0,
row: 64.0,
};
pub const COMFY: DensityTokens = DensityTokens {
pad: 20.0,
gap: 18.0,
radius: 22.0,
row: 72.0,
};
/// Current density tokens from app config (comfy is the product default).
pub fn density() -> DensityTokens {
match AppConfig::density() {
DensityKind::Compact => COMPACT,
DensityKind::Regular => REGULAR,
DensityKind::Comfy => COMFY,
}
}
/// Font family helpers for the Geist weight stack registered in `setup_fonts`.
pub mod fonts {
use egui::{FontFamily, FontId};
pub fn regular() -> FontFamily {
FontFamily::Proportional
}
pub fn medium() -> FontFamily {
FontFamily::Name("geist-medium".into())
}
pub fn semibold() -> FontFamily {
FontFamily::Name("geist-semibold".into())
}
pub fn bold() -> FontFamily {
FontFamily::Name("geist-bold".into())
}
pub fn mono() -> FontFamily {
FontFamily::Monospace
}
pub fn mono_semibold() -> FontFamily {
FontFamily::Name("geist-mono-sb".into())
}
/// Uppercase kicker label size (11px in the design).
pub fn kicker() -> FontId {
FontId::new(11.0, semibold())
}
}
/// Pick a readable ink (black or white) for the given background by luminance.
pub fn ink_for(bg: Color32) -> Color32 {
let lum = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
if lum > 140.0 {
Color32::from_rgb(0x0E, 0x0E, 0x0C)
} else {
Color32::from_rgb(0xFA, 0xFA, 0xF7)
}
}
/// Avatar (background, ink) pair for a hue index.
pub fn avatar_pair(hue: usize) -> (Color32, Color32) {
let pairs = &tokens().avatar_pairs;
pairs[hue % pairs.len()]
}
/// Number of avatar color pairs (hue derivation modulus).
pub fn avatar_pairs_len() -> usize {
tokens().avatar_pairs.len()
}
+471 -389
View File
@@ -22,425 +22,507 @@ 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::gui::Colors;
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) {
let rect = if let Some(img_data) = cb.camera_image() {
if let Ok(img) = image::load_from_memory(&*img_data.0) {
// Process image to find QR code.
self.scan_qr(&img);
/// Draw camera content.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let rect = if let Some(img_data) = cb.camera_image() {
if let Ok(img) = image::load_from_memory(&*img_data.0) {
// Process image to find QR code.
self.scan_qr(&img);
// Draw image.
let img_rect = self.image_ui(ui, img, img_data.1);
// Draw image.
let img_rect = self.image_ui(ui, img, img_data.1);
img_rect
} else {
self.loading_ui(ui)
}
} else {
self.loading_ui(ui)
};
img_rect
} else {
self.loading_ui(ui)
}
} else {
self.loading_ui(ui)
};
// Show UR scan progress.
self.ur_progress_ui(ui, &rect);
// Show UR scan progress.
self.ur_progress_ui(ui, &rect);
// Show button to switch cameras.
if cb.can_switch_camera() {
let r = {
let mut r = rect.clone();
r.min.y = r.max.y - 52.0;
r.min.x = r.max.x - 52.0;
r
};
ui.scope_builder(UiBuilder::new().max_rect(r), |ui| {
let rotate_img = CAMERA_ROTATE.to_string();
View::button(ui, rotate_img, Colors::white_or_black(false), || {
cb.switch_camera();
});
});
}
ui.add_space(12.0);
ui.ctx().request_repaint();
}
// Show button to switch cameras.
if cb.can_switch_camera() {
let r = {
let mut r = rect.clone();
r.min.y = r.max.y - 52.0;
r.min.x = r.max.x - 52.0;
r
};
ui.scope_builder(UiBuilder::new().max_rect(r), |ui| {
let rotate_img = CAMERA_ROTATE.to_string();
View::button(ui, rotate_img, Colors::white_or_black(false), || {
cb.switch_camera();
});
});
}
ui.add_space(6.0);
ui.ctx().request_repaint();
}
/// Draw camera image.
fn image_ui(&mut self, ui: &mut egui::Ui, mut img: DynamicImage, rotation: u32) -> Rect {
// Setup image rotation.
img = match rotation {
90 => img.rotate90(),
180 => img.rotate180(),
270 => img.rotate270(),
_ => img
};
if View::is_desktop() {
img = img.fliph();
}
// Convert to ColorImage.
let color_img = match &img {
DynamicImage::ImageRgb8(image) => {
egui::ColorImage::from_rgb(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
)
},
other => {
let image = other.to_rgba8();
egui::ColorImage::from_rgba_unmultiplied(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
)
},
};
// Create image texture.
let texture = ui.ctx().load_texture("camera_image",
color_img.clone(),
TextureOptions::default());
let img_size = egui::emath::vec2(color_img.width() as f32,
color_img.height() as f32);
let sized_img = SizedTexture::new(texture.id(), img_size);
egui::Image::from_texture(sized_img)
// Setup to crop image at square.
.uv(Rect::from([
if img_size.y > img_size.x {
Pos2::new(0.0, 1.0 - (img_size.x / img_size.y))
} else {
Pos2::new(1.0 - (img_size.y / img_size.x), 0.0)
},
Pos2::new(1.0, 1.0)
]))
.max_height(ui.available_width())
.maintain_aspect_ratio(false)
.shrink_to_fit()
.ui(ui).rect
}
/// Draw camera image.
fn image_ui(&mut self, ui: &mut egui::Ui, mut img: DynamicImage, rotation: u32) -> Rect {
// Setup image rotation.
img = match rotation {
90 => img.rotate90(),
180 => img.rotate180(),
270 => img.rotate270(),
_ => img,
};
if View::is_desktop() {
img = img.fliph();
}
// Convert to ColorImage.
let color_img = match &img {
DynamicImage::ImageRgb8(image) => egui::ColorImage::from_rgb(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
),
other => {
let image = other.to_rgba8();
egui::ColorImage::from_rgba_unmultiplied(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
)
}
};
// Create image texture.
let texture =
ui.ctx()
.load_texture("camera_image", color_img.clone(), TextureOptions::default());
let img_size = egui::emath::vec2(color_img.width() as f32, color_img.height() as f32);
let sized_img = SizedTexture::new(texture.id(), img_size);
egui::Image::from_texture(sized_img)
// Setup to crop image at square.
.uv(Rect::from([
if img_size.y > img_size.x {
Pos2::new(0.0, 1.0 - (img_size.x / img_size.y))
} else {
Pos2::new(1.0 - (img_size.y / img_size.x), 0.0)
},
Pos2::new(1.0, 1.0),
]))
.max_height(ui.available_width())
.maintain_aspect_ratio(false)
.shrink_to_fit()
.ui(ui)
.rect
}
/// Draw animated QR code scanning progress.
fn ur_progress_ui(&self, ui: &mut egui::Ui, rect: &Rect) {
let show_ur_progress = {
self.ur_data.as_ref().read().is_some()
};
if show_ur_progress {
ui.scope_builder(UiBuilder::new().max_rect(rect.clone()), |ui| {
ui.centered_and_justified(|ui| {
ui.label(RichText::new(format!("{}%", self.ur_progress()))
.size(32.0)
.color(Colors::gold_dark()));
});
});
}
}
/// Draw animated QR code scanning progress.
fn ur_progress_ui(&self, ui: &mut egui::Ui, rect: &Rect) {
let show_ur_progress = { self.ur_data.as_ref().read().is_some() };
if show_ur_progress {
ui.scope_builder(UiBuilder::new().max_rect(rect.clone()), |ui| {
ui.centered_and_justified(|ui| {
ui.label(
RichText::new(format!("{}%", self.ur_progress()))
.size(32.0)
.color(Colors::gold_dark()),
);
});
});
}
}
/// Draw camera loading progress content.
fn loading_ui(&self, ui: &mut egui::Ui) -> 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
}
/// Draw camera loading progress content, or a missing-camera notice when
/// no frame ever arrives (no device, device busy, or capture failed).
fn loading_ui(&self, ui: &mut egui::Ui) -> Rect {
if self.wait_start.elapsed().as_secs() >= 5 {
let space = ui.available_width() / 3.0;
return ui
.vertical_centered(|ui| {
ui.add_space(space);
ui.label(
RichText::new("No camera found")
.size(17.0)
.color(Colors::inactive_text()),
);
ui.add_space(space);
})
.response
.rect;
}
let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0;
ui.vertical_centered(|ui| {
ui.add_space(space);
View::big_loading_spinner(ui);
ui.add_space(space);
})
.response
.rect
}
/// Check if image is processing to find QR code.
fn image_processing(&self) -> bool {
let r_scan = self.qr_scan_state.read();
r_scan.image_processing
}
/// Check if image is processing to find QR code.
fn image_processing(&self) -> bool {
let r_scan = self.qr_scan_state.read();
r_scan.image_processing
}
/// Get UR scanning progress in percents.
fn ur_progress(&self) -> i32 {
// Setup data.
let r_data = self.ur_data.read();
let (data, total) = r_data.clone().unwrap_or((vec![], 0));
if data.is_empty() {
return 0;
}
// Calculate progress.
let mut complete = 0;
for i in &data {
if !i.is_empty() {
complete += 1;
}
}
(100 * complete / total) as i32
}
/// Get UR scanning progress in percents.
fn ur_progress(&self) -> i32 {
// Setup data.
let r_data = self.ur_data.read();
let (data, total) = r_data.clone().unwrap_or((vec![], 0));
if data.is_empty() {
return 0;
}
// Calculate progress.
let mut complete = 0;
for i in &data {
if !i.is_empty() {
complete += 1;
}
}
(100 * complete / total) as i32
}
/// Parse QR code from provided image data.
fn scan_qr(&self, image_data: &DynamicImage) {
// Do not scan when another image is processing.
if self.image_processing() {
return;
}
// Setup scanning flag.
{
let mut w_scan = self.qr_scan_state.write();
w_scan.image_processing = true;
}
/// Parse QR code from provided image data.
fn scan_qr(&self, image_data: &DynamicImage) {
// Do not scan when another image is processing.
if self.image_processing() {
return;
}
// Setup scanning flag.
{
let mut w_scan = self.qr_scan_state.write();
w_scan.image_processing = true;
}
let image_data = image_data.clone();
let qr_scan_state = self.qr_scan_state.clone();
let ur_data = self.ur_data.clone();
let image_data = image_data.clone();
let qr_scan_state = self.qr_scan_state.clone();
let ur_data = self.ur_data.clone();
let on_scan = async move {
// Prepare image data.
let img = image_data.to_luma8();
let mut img: rqrr::PreparedImage<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;
}
};
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;
}
};
// Launch scanner at separate thread.
thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(on_scan);
});
}
// Launch scanner at separate thread.
thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(on_scan);
});
}
/// Parse QR code scan result.
fn parse_qr_code(data: Vec<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));
}
}
/// 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 if string contains Slatepack message prefix and postfix.
if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") {
return QrScanResult::Slatepack(text.to_string());
}
// Check if string contains Slatepack message prefix and postfix.
if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") {
return QrScanResult::Slatepack(text.to_string());
}
// Check Uniform Resource data.
// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md
if text.starts_with("ur:bytes/") {
let split = text.split("/").collect::<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);
}
}
}
}
// 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);
}
}
}
}
// Check Compact SeedQR format.
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#compactseedqr-specification
if data.len() <= 32 && 16 <= data.len() && data.len() % 4 == 0 {
// Setup words amount.
let total_bits = data.len() * 8;
let checksum_bits = total_bits / 32;
let total_words = (total_bits + checksum_bits) / 11;
// Setup entropy.
let mut entropy = data.clone();
WalletUtils::setup_checksum(&mut entropy);
// Setup bits.
let mut bits = vec![false; entropy.len() * 8];
for i in 0..entropy.len() {
for j in 0..8 {
bits[(i * 8) + j] = (entropy[i] & (1 << (7 - j))) != 0;
}
}
// Extract word index.
let extract_index = |i: usize| -> usize {
let mut index = 0;
for j in 0..11 {
index = index << 1;
if bits[(i * 11) + j] {
index += 1;
}
}
return index;
};
// Setup words.
let mut words = "".to_string();
for n in 0..total_words {
// Setup word index.
let index = extract_index(n);
// Setup word.
let empty_word = "".to_string();
let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone();
if word.is_empty() {
words = empty_word;
break;
}
words = if words.is_empty() {
format!("{}", word)
} else {
format!("{} {}", words, word)
};
}
if !words.is_empty() {
return QrScanResult::SeedQR(ZeroingString::from(words));
}
}
// Check Compact SeedQR format.
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#compactseedqr-specification
if data.len() <= 32 && 16 <= data.len() && data.len() % 4 == 0 {
// Setup words amount.
let total_bits = data.len() * 8;
let checksum_bits = total_bits / 32;
let total_words = (total_bits + checksum_bits) / 11;
// Setup entropy.
let mut entropy = data.clone();
WalletUtils::setup_checksum(&mut entropy);
// Setup bits.
let mut bits = vec![false; entropy.len() * 8];
for i in 0..entropy.len() {
for j in 0..8 {
bits[(i * 8) + j] = (entropy[i] & (1 << (7 - j))) != 0;
}
}
// Extract word index.
let extract_index = |i: usize| -> usize {
let mut index = 0;
for j in 0..11 {
index = index << 1;
if bits[(i * 11) + j] {
index += 1;
}
}
return index;
};
// Setup words.
let mut words = "".to_string();
for n in 0..total_words {
// Setup word index.
let index = extract_index(n);
// Setup word.
let empty_word = "".to_string();
let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone();
if word.is_empty() {
words = empty_word;
break;
}
words = if words.is_empty() {
format!("{}", word)
} else {
format!("{} {}", words, word)
};
}
if !words.is_empty() {
return QrScanResult::SeedQR(ZeroingString::from(words));
}
}
// Check Standard SeedQR format.
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#standard-seedqr-specification
let only_numbers = || {
for c in text.chars() {
if !c.is_numeric() {
return false;
}
}
true
};
if !text.is_empty() && data.len() <= 96 && data.len() % 4 == 0 && only_numbers() {
if let Some(_) = PhraseSize::type_for_value(text.len() / 4) {
let chars: Vec<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 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))
}
// 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
}
}
/// 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()),
}
}
}
+300 -250
View File
@@ -12,57 +12,57 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::os::OperatingSystem;
use egui::RichText;
use egui::os::OperatingSystem;
use lazy_static::lazy_static;
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::gui::Colors;
use crate::gui::icons::FILE_X;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::network::NetworkContent;
use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::wallets::WalletsContent;
use crate::gui::views::{Modal, View};
use crate::gui::Colors;
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,
/// Side panel [`NetworkContent`] content.
network: NetworkContent,
/// Central panel [`WalletsContent`] content.
wallets: WalletsContent,
/// Central panel [`WalletsContent`] content.
wallets: WalletsContent,
/// Check if app exit is allowed on Desktop close event.
pub exit_allowed: bool,
/// Flag to show exit progress at [`Modal`].
show_exit_progress: bool,
/// Check if app exit is allowed on Desktop close event.
pub exit_allowed: bool,
/// Flag to show exit progress at [`Modal`].
show_exit_progress: bool,
/// Flag to check it's first draw of content.
first_draw: bool,
/// 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,
}
}
fn default() -> Self {
// Exit from eframe only for non-mobile platforms.
let os = OperatingSystem::from_target_os();
let exit_allowed = os == OperatingSystem::Android || os == OperatingSystem::IOS;
Self {
network: NetworkContent::default(),
wallets: WalletsContent::default(),
exit_allowed,
show_exit_progress: false,
first_draw: true,
}
}
}
/// Identifier for integrated node warning [`Modal`] on Android.
@@ -71,250 +71,300 @@ const ANDROID_INTEGRATED_NODE_WARNING_MODAL: &'static str = "android_node_warnin
const CRASH_REPORT_MODAL: &'static str = "crash_report_modal";
impl ContentContainer for Content {
fn modal_ids(&self) -> Vec<&'static str> {
vec![
Self::EXIT_CONFIRMATION_MODAL,
ANDROID_INTEGRATED_NODE_WARNING_MODAL,
CRASH_REPORT_MODAL
]
}
fn modal_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 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();
}
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, is_panel_open, |ui| {
self.network.ui(ui, cb);
});
// 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);
});
// 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_report_path().exists() {
Modal::new(CRASH_REPORT_MODAL)
.closeable(false)
.position(ModalPosition::Center)
.title(t!("crash_report"))
.show();
} else if OperatingSystem::from_target_os() == OperatingSystem::Android &&
AppConfig::android_integrated_node_warning_needed() {
Modal::new(ANDROID_INTEGRATED_NODE_WARNING_MODAL)
.title(t!("network.node"))
.show();
}
self.first_draw = false;
}
}
if self.first_draw {
// Show crash report or integrated node Android warning.
if Settings::crash_check_path().exists() {
Modal::new(CRASH_REPORT_MODAL)
.closeable(false)
.position(ModalPosition::Center)
.title(t!("crash_report"))
.show();
} else if OperatingSystem::from_target_os() == OperatingSystem::Android
&& AppConfig::android_integrated_node_warning_needed()
{
Modal::new(ANDROID_INTEGRATED_NODE_WARNING_MODAL)
.title(t!("network.node"))
.show();
}
self.first_draw = false;
}
}
}
impl Content {
/// Default width of side panel at application UI.
pub const SIDE_PANEL_WIDTH: f32 = 400.0;
/// Desktop window title height.
pub const WINDOW_TITLE_HEIGHT: f32 = 38.0;
/// Margin of window frame at desktop.
pub const WINDOW_FRAME_MARGIN: f32 = 6.0;
/// Default width of side panel at application UI.
pub const SIDE_PANEL_WIDTH: f32 = 400.0;
/// Desktop window title height.
pub const WINDOW_TITLE_HEIGHT: f32 = 38.0;
/// Margin of window frame at desktop.
pub const WINDOW_FRAME_MARGIN: f32 = 6.0;
/// Identifier for exit confirmation [`Modal`].
pub const EXIT_CONFIRMATION_MODAL: &'static str = "exit_confirmation_modal";
/// Identifier for exit confirmation [`Modal`].
pub const EXIT_CONFIRMATION_MODAL: &'static str = "exit_confirmation_modal";
/// Called to navigate back, return `true` if action was not consumed.
pub fn on_back(&mut self, ctx: &egui::Context, cb: &dyn PlatformCallbacks) -> bool {
if Modal::on_back() {
let dual_panel = Self::is_dual_panel_mode(ctx);
if !dual_panel && Self::is_network_panel_open() {
if self.network.on_back() {
Self::toggle_network_panel();
return false;
}
} else if self.wallets.on_back(cb) {
Self::show_exit_modal();
return false;
}
}
true
}
/// Called to navigate back, return `true` if action was not consumed.
pub fn on_back(&mut self, ctx: &egui::Context, cb: &dyn PlatformCallbacks) -> bool {
if Modal::on_back() {
let dual_panel = Self::is_dual_panel_mode(ctx);
if !dual_panel && Self::is_network_panel_open() {
if self.network.on_back() {
Self::toggle_network_panel();
return false;
}
} else if self.wallets.on_back(cb) {
Self::show_exit_modal();
return false;
}
}
true
}
/// Check if ui can show [`NetworkContent`] and [`WalletsContent`] at same time.
pub fn is_dual_panel_mode(ctx: &egui::Context) -> bool {
let (w, h) = View::window_size(ctx);
// Screen is wide if width is greater than height or just 20% smaller.
let is_wide_screen = w > h || w + (w * 0.2) >= h;
// Dual panel mode is available when window is wide and its width is at least 2 times
// greater than minimal width of the side panel plus display insets from both sides.
let side_insets = View::get_left_inset() + View::get_right_inset();
is_wide_screen && w >= (Self::SIDE_PANEL_WIDTH * 2.0) + side_insets
}
/// Check if ui can show [`NetworkContent`] and [`WalletsContent`] at same time.
pub fn is_dual_panel_mode(ctx: &egui::Context) -> bool {
let (w, h) = View::window_size(ctx);
// Screen is wide if width is greater than height or just 20% smaller.
let is_wide_screen = w > h || w + (w * 0.2) >= h;
// Dual panel mode is available when window is wide and its width is at least 2 times
// greater than minimal width of the side panel plus display insets from both sides.
let side_insets = View::get_left_inset() + View::get_right_inset();
is_wide_screen && w >= (Self::SIDE_PANEL_WIDTH * 2.0) + side_insets
}
/// Toggle [`NetworkContent`] panel state.
pub fn toggle_network_panel() {
let is_open = NETWORK_PANEL_OPEN.load(Ordering::Relaxed);
NETWORK_PANEL_OPEN.store(!is_open, Ordering::Relaxed);
}
/// Toggle [`NetworkContent`] panel state.
pub fn toggle_network_panel() {
let is_open = NETWORK_PANEL_OPEN.load(Ordering::Relaxed);
NETWORK_PANEL_OPEN.store(!is_open, Ordering::Relaxed);
}
/// Check if [`NetworkContent`] panel is open.
pub fn is_network_panel_open() -> bool {
NETWORK_PANEL_OPEN.load(Ordering::Relaxed)
}
/// Check if [`NetworkContent`] panel is open.
pub fn is_network_panel_open() -> bool {
NETWORK_PANEL_OPEN.load(Ordering::Relaxed)
}
/// Show exit confirmation [`Modal`].
pub fn show_exit_modal() {
Modal::new(Self::EXIT_CONFIRMATION_MODAL)
.title(t!("confirmation"))
.show();
}
/// Show exit confirmation [`Modal`].
pub fn show_exit_modal() {
Modal::new(Self::EXIT_CONFIRMATION_MODAL)
.title(t!("confirmation"))
.show();
}
/// Draw exit confirmation modal content.
fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
if self.show_exit_progress {
if !Node::is_running() && !Node::data_dir_changing() {
self.exit_allowed = true;
cb.exit();
Modal::close();
}
ui.add_space(16.0);
ui.vertical_centered(|ui| {
View::small_loading_spinner(ui);
ui.add_space(12.0);
let exit_status_text = if Node::data_dir_changing() {
t!("moving_files")
} else {
t!("sync_status.shutdown")
};
ui.label(RichText::new(exit_status_text)
.size(17.0)
.color(Colors::text(false)));
});
ui.add_space(10.0);
} else {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("modal_exit.description"))
.size(17.0)
.color(Colors::text(false)));
});
ui.add_space(10.0);
/// Draw exit confirmation modal content.
fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
if self.show_exit_progress {
if !Node::is_running() && !Node::data_dir_changing() {
self.exit_allowed = true;
cb.exit();
Modal::close();
}
ui.add_space(16.0);
ui.vertical_centered(|ui| {
View::small_loading_spinner(ui);
ui.add_space(12.0);
let exit_status_text = if Node::data_dir_changing() {
t!("moving_files")
} else {
t!("sync_status.shutdown")
};
ui.label(
RichText::new(exit_status_text)
.size(17.0)
.color(Colors::text(false)),
);
});
ui.add_space(10.0);
} else {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("modal_exit.description"))
.size(17.0)
.color(Colors::text(false)),
);
});
ui.add_space(10.0);
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
Modal::close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button_ui(ui, t!("modal_exit.exit"), Colors::white_or_black(false), |_| {
if !Node::is_running() && !Node::data_dir_changing() {
self.exit_allowed = true;
cb.exit();
Modal::close();
} else {
Node::stop(true);
modal.disable_closing();
Modal::set_title(t!("modal_exit.exit"));
self.show_exit_progress = true;
}
});
});
});
ui.add_space(6.0);
}
}
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button_ui(
ui,
t!("modal_exit.exit"),
Colors::white_or_black(false),
|_| {
if !Node::is_running() && !Node::data_dir_changing() {
self.exit_allowed = true;
cb.exit();
Modal::close();
} else {
Node::stop(true);
modal.disable_closing();
Modal::set_title(t!("modal_exit.exit"));
self.show_exit_progress = true;
}
},
);
});
});
ui.add_space(6.0);
}
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn android_warning_modal_ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("network.android_warning"))
.size(16.0)
.color(Colors::text(false)));
});
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
AppConfig::show_android_integrated_node_warning();
Modal::close();
});
});
ui.add_space(6.0);
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn android_warning_modal_ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("network.android_warning"))
.size(16.0)
.color(Colors::text(false)),
);
});
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
AppConfig::show_android_integrated_node_warning();
Modal::close();
});
});
ui.add_space(6.0);
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn crash_report_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("crash_report_warning"))
.size(16.0)
.color(Colors::text(false)));
ui.add_space(6.0);
// Draw button to share crash report.
let text = format!("{} {}", FILE_X, t!("share"));
View::colored_text_button(ui, text, Colors::blue(), Colors::white_or_black(false), || {
if let Ok(data) = fs::read_to_string(Settings::crash_report_path()) {
let name = Settings::CRASH_REPORT_FILE_NAME.to_string();
let _ = cb.share_data(name, data.as_bytes().to_vec());
}
Settings::delete_crash_report();
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_report();
Modal::close();
});
});
ui.add_space(6.0);
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn crash_report_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("crash_report_warning"))
.size(16.0)
.color(Colors::text(false)),
);
ui.add_space(6.0);
// Draw button to share log file.
let text = format!("{} {}", FILE_X, t!("share"));
View::colored_text_button(
ui,
text,
Colors::blue(),
Colors::white_or_black(false),
|| {
if let Ok(data) = fs::read_to_string(Settings::log_path()) {
let name = Settings::LOG_FILE_NAME.to_string();
let _ = cb.share_data(name, data.as_bytes().to_vec());
}
Settings::delete_crash_check();
Modal::close();
},
);
});
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Settings::delete_crash_check();
Modal::close();
},
);
});
ui.add_space(6.0);
}
}
/// Get [`NetworkContent`] panel state and width.
fn network_panel_state_width(ctx: &egui::Context, dual_panel: bool) -> (bool, f32) {
let is_panel_open = dual_panel || Content::is_network_panel_open();
let panel_width = if dual_panel {
Content::SIDE_PANEL_WIDTH + View::get_left_inset()
} else {
let is_fullscreen = ctx.input(|i| {
i.viewport().fullscreen.unwrap_or(false)
});
View::window_size(ctx).0 - if View::is_desktop() && !is_fullscreen &&
OperatingSystem::from_target_os() != OperatingSystem::Mac {
Content::WINDOW_FRAME_MARGIN * 2.0
} else {
0.0
}
};
(is_panel_open, panel_width)
}
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)
}
+154 -151
View File
@@ -14,179 +14,182 @@
use egui::CornerRadius;
use parking_lot::RwLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{fs, thread};
use crate::gui::Colors;
use crate::gui::icons::ARCHIVE_BOX;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::View;
use crate::gui::Colors;
/// Type of button.
pub enum FilePickContentType {
Button(String), ItemButton(CornerRadius), Tab
Button(String),
ItemButton(CornerRadius),
Tab,
}
/// Button to pick file and parse its data into text.
pub struct FilePickContent {
/// Content type.
content_type: FilePickContentType,
/// Content type.
content_type: FilePickContentType,
/// Flag to check if button is active.
active: bool,
/// Flag to check if button is active.
active: bool,
/// Flag to check if file is picking.
file_picking: Arc<AtomicBool>,
/// Flag to check if file is picking.
file_picking: Arc<AtomicBool>,
/// Flag to check if folder should be picked.
pick_folder: bool,
/// Flag to parse file content after pick.
parse_file: bool,
/// Flag to check if file is parsing.
file_parsing: Arc<AtomicBool>,
/// File parsing result.
file_parsing_result: Arc<RwLock<Option<String>>>,
/// Flag to check if folder should be picked.
pick_folder: bool,
/// Flag to parse file content after pick.
parse_file: bool,
/// Flag to check if file is parsing.
file_parsing: Arc<AtomicBool>,
/// File parsing result.
file_parsing_result: Arc<RwLock<Option<String>>>,
}
impl FilePickContent {
/// Create new content from provided type.
pub fn new(content_type: FilePickContentType) -> Self {
Self {
content_type,
active: false,
file_picking: Arc::new(AtomicBool::new(false)),
pick_folder: false,
parse_file: true,
file_parsing: Arc::new(AtomicBool::new(false)),
file_parsing_result: Arc::new(RwLock::new(None)),
}
}
/// Create new content from provided type.
pub fn new(content_type: FilePickContentType) -> Self {
Self {
content_type,
active: false,
file_picking: Arc::new(AtomicBool::new(false)),
pick_folder: false,
parse_file: true,
file_parsing: Arc::new(AtomicBool::new(false)),
file_parsing_result: Arc::new(RwLock::new(None)),
}
}
/// Pick folder.
pub fn pick_folder(mut self) -> Self {
self.pick_folder = true;
self
}
/// Pick folder.
pub fn pick_folder(mut self) -> Self {
self.pick_folder = true;
self
}
/// Do not parse file content.
pub fn no_parse(mut self) -> Self {
self.parse_file = false;
self
}
/// Do not parse file content.
pub fn no_parse(mut self) -> Self {
self.parse_file = false;
self
}
/// Enable or disable the button.
pub fn set_active(&mut self, active: bool) {
self.active = active;
}
/// Enable or disable the button.
pub fn set_active(&mut self, active: bool) {
self.active = active;
}
/// Draw content with provided callback to return path of the file.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks, pick: impl FnOnce(String)) {
if self.file_picking.load(Ordering::Relaxed) {
View::small_loading_spinner(ui);
// Check file pick result.
if let Some(path) = cb.picked_file() {
self.file_picking.store(false, Ordering::Relaxed);
if !path.is_empty() {
if self.parse_file {
self.parse_file(path);
} else {
pick(path);
}
}
}
} else if self.file_parsing.load(Ordering::Relaxed) {
View::small_loading_spinner(ui);
// Check file parsing result.
let has_result = {
let r_res = self.file_parsing_result.read();
r_res.is_some()
};
if has_result {
let text = {
let r_res = self.file_parsing_result.read();
r_res.clone().unwrap()
};
// Callback on result.
pick(text);
// Clear result.
let mut w_res = self.file_parsing_result.write();
*w_res = None;
self.file_parsing.store(false, Ordering::Relaxed);
}
} else {
// Draw button to pick file.
match &self.content_type {
FilePickContentType::Button(text) => {
let text = format!("{} {}", ARCHIVE_BOX, text);
let text_color = Colors::blue();
let fill = Colors::white_or_black(false);
View::colored_text_button(ui, text, text_color, fill, || {
self.on_file_pick(pick, cb);
});
}
FilePickContentType::ItemButton(r) => {
View::item_button(ui, r.clone(), ARCHIVE_BOX, Some(Colors::blue()), || {
self.on_file_pick(pick, cb);
});
}
FilePickContentType::Tab => {
let active = match self.active {
true => Some(self.file_parsing.load(Ordering::Relaxed) ||
self.file_picking.load(Ordering::Relaxed)),
false => None
};
View::tab_button(ui, ARCHIVE_BOX, Some(Colors::blue()), active, |_| {
self.on_file_pick(pick, cb);
});
}
}
}
}
/// Draw content with provided callback to return path of the file.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks, pick: impl FnOnce(String)) {
if self.file_picking.load(Ordering::Relaxed) {
View::small_loading_spinner(ui);
// Check file pick result.
if let Some(path) = cb.picked_file() {
self.file_picking.store(false, Ordering::Relaxed);
if !path.is_empty() {
if self.parse_file {
self.parse_file(path);
} else {
pick(path);
}
}
}
} else if self.file_parsing.load(Ordering::Relaxed) {
View::small_loading_spinner(ui);
// Check file parsing result.
let has_result = {
let r_res = self.file_parsing_result.read();
r_res.is_some()
};
if has_result {
let text = {
let r_res = self.file_parsing_result.read();
r_res.clone().unwrap()
};
// Callback on result.
pick(text);
// Clear result.
let mut w_res = self.file_parsing_result.write();
*w_res = None;
self.file_parsing.store(false, Ordering::Relaxed);
}
} else {
// Draw button to pick file.
match &self.content_type {
FilePickContentType::Button(text) => {
let text = format!("{} {}", ARCHIVE_BOX, text);
let text_color = Colors::blue();
let fill = Colors::white_or_black(false);
View::colored_text_button(ui, text, text_color, fill, || {
self.on_file_pick(pick, cb);
});
}
FilePickContentType::ItemButton(r) => {
View::item_button(ui, r.clone(), ARCHIVE_BOX, Some(Colors::blue()), || {
self.on_file_pick(pick, cb);
});
}
FilePickContentType::Tab => {
let active = match self.active {
true => Some(
self.file_parsing.load(Ordering::Relaxed)
|| self.file_picking.load(Ordering::Relaxed),
),
false => None,
};
View::tab_button(ui, ARCHIVE_BOX, Some(Colors::blue()), active, |_| {
self.on_file_pick(pick, cb);
});
}
}
}
}
/// Handle pick file request.
fn on_file_pick(&self, on_pick: impl FnOnce(String), cb: &dyn PlatformCallbacks) {
let path = if self.pick_folder {
cb.pick_folder()
} else {
cb.pick_file()
};
if path.is_none() {
return;
}
let path = path.unwrap();
// Wait for asynchronous file pick result if path is empty.
if path.is_empty() {
self.file_picking.store(true, Ordering::Relaxed);
return;
}
// Parse result if needed.
if self.parse_file {
self.parse_file(path);
} else {
on_pick(path);
}
}
/// Handle pick file request.
fn on_file_pick(&self, on_pick: impl FnOnce(String), cb: &dyn PlatformCallbacks) {
let path = if self.pick_folder {
cb.pick_folder()
} else {
cb.pick_file()
};
if path.is_none() {
return;
}
let path = path.unwrap();
// Wait for asynchronous file pick result if path is empty.
if path.is_empty() {
self.file_picking.store(true, Ordering::Relaxed);
return;
}
// Parse result if needed.
if self.parse_file {
self.parse_file(path);
} else {
on_pick(path);
}
}
/// Handle picked file path.
fn parse_file(&self, path: String) {
self.file_parsing.store(true, Ordering::Relaxed);
let result = self.file_parsing_result.clone();
thread::spawn(move || {
if path.ends_with(".gif") {
//TODO: Detect QR codes on GIF file.
} else if path.ends_with(".jpeg") || path.ends_with(".jpg") ||
path.ends_with(".png") {
//TODO: Detect QR codes on image files.
} else {
// Parse file as plain text.
let mut w_res = result.write();
if let Ok(text) = fs::read_to_string(path) {
*w_res = Some(text);
} else {
*w_res = Some("".to_string());
}
}
});
}
}
/// Handle picked file path.
fn parse_file(&self, path: String) {
self.file_parsing.store(true, Ordering::Relaxed);
let result = self.file_parsing_result.clone();
thread::spawn(move || {
if path.ends_with(".gif") {
//TODO: Detect QR codes on GIF file.
} else if path.ends_with(".jpeg") || path.ends_with(".jpg") || path.ends_with(".png") {
//TODO: Detect QR codes on image files.
} else {
// Parse file as plain text.
let mut w_res = result.write();
if let Ok(text) = fs::read_to_string(path) {
*w_res = Some(text);
} else {
*w_res = Some("".to_string());
}
}
});
}
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Texture layer over the avatar disk cache: hands the UI ready
//! [`egui::TextureHandle`]s for usernames, fetching stale entries from the
//! NIP-05 server on background threads. Textures are only created on the UI
//! thread; workers send raw PNG bytes back over a channel.
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, Sender, channel};
use crate::nostr::avatar::AvatarCache;
use crate::nostr::nip05;
use crate::settings::Settings;
/// Worker outcome for one name's avatar probe.
enum Fetched {
/// A custom avatar (content hash, png bytes).
Found(String, Vec<u8>),
/// The server confirmed the name has no avatar.
Absent,
/// The probe failed (network/Tor) — do NOT cache; retry later.
Failed,
}
type FetchResult = (String, Fetched);
pub struct AvatarTextures {
cache: AvatarCache,
/// Ready textures; `None` records a known letter-fallback (no avatar).
textures: HashMap<String, Option<egui::TextureHandle>>,
inflight: HashSet<String>,
tx: Sender<FetchResult>,
rx: Receiver<FetchResult>,
}
impl Default for AvatarTextures {
fn default() -> Self {
let (tx, rx) = channel();
Self {
cache: AvatarCache::new(Settings::base_path(Some("cache/avatars".to_string()))),
textures: HashMap::new(),
inflight: HashSet::new(),
tx,
rx,
}
}
}
fn decode(png: &[u8]) -> Option<egui::ColorImage> {
// Server-fed bytes: decode under explicit limits so a hostile or breached
// avatar host can't blow up memory on the texture path. `fetch_avatar`
// only checks ≤1 MiB + PNG magic, not the decoded dimensions.
let mut reader = image::ImageReader::new(std::io::Cursor::new(png));
reader.set_format(image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(1024);
limits.max_image_height = Some(1024);
limits.max_alloc = Some(8 * 1024 * 1024);
reader.limits(limits);
let img = reader.decode().ok()?.to_rgba8();
Some(egui::ColorImage::from_rgba_unmultiplied(
[img.width() as usize, img.height() as usize],
img.as_raw(),
))
}
impl AvatarTextures {
/// Texture for a bare username (no `@`), if it has a custom avatar.
/// Triggers a background refresh when the cache entry is stale.
pub fn texture_for(
&mut self,
ctx: &egui::Context,
server: &str,
name: &str,
) -> Option<egui::TextureHandle> {
self.drain(ctx);
let name = name.trim_start_matches('@').to_lowercase();
if name.is_empty() {
return None;
}
if let Some(t) = self.textures.get(&name).cloned() {
// A known state (texture or confirmed-absent); refresh if stale.
if self.cache.stale(&name) {
self.spawn_fetch(server, &name);
}
return t;
}
// Disk cache hit → texture now, refresh in background if stale.
if let Some((_, bytes)) = self.cache.cached(&name) {
let tex = decode(&bytes)
.map(|img| ctx.load_texture(format!("avatar_{name}"), img, Default::default()));
self.textures.insert(name.clone(), tex.clone());
if self.cache.stale(&name) {
self.spawn_fetch(server, &name);
}
return tex;
}
if self.cache.stale(&name) {
self.spawn_fetch(server, &name);
} else {
// Fresh negative entry: letter fallback without re-probing.
self.textures.insert(name.clone(), None);
}
None
}
/// Install the just-uploaded avatar without waiting for a round-trip.
pub fn set_own(&mut self, ctx: &egui::Context, name: &str, hash: &str, png: &[u8]) {
let name = name.trim_start_matches('@').to_lowercase();
self.cache.store(&name, hash, png);
let tex = decode(png)
.map(|img| ctx.load_texture(format!("avatar_{name}"), img, Default::default()));
self.textures.insert(name, tex);
}
/// Forget a name (released or rotated away).
pub fn invalidate(&mut self, name: &str) {
let name = name.trim_start_matches('@').to_lowercase();
self.cache.remove(&name);
self.textures.remove(&name);
}
fn drain(&mut self, ctx: &egui::Context) {
while let Ok((name, fetched)) = self.rx.try_recv() {
self.inflight.remove(&name);
match fetched {
Fetched::Found(hash, png) => {
self.cache.store(&name, &hash, &png);
let tex = decode(&png).map(|img| {
ctx.load_texture(format!("avatar_{name}"), img, Default::default())
});
self.textures.insert(name, tex);
}
Fetched::Absent => {
self.cache.mark_absent(&name);
self.textures.insert(name, None);
}
// Network/Tor failure: leave the entry stale so the next
// frame retries once a circuit is healthy. Never cache it as
// a confirmed "no avatar".
Fetched::Failed => {}
}
ctx.request_repaint();
}
}
fn spawn_fetch(&mut self, server: &str, name: &str) {
if self.inflight.contains(name) {
return;
}
self.inflight.insert(name.to_string());
let tx = self.tx.clone();
let server = server.to_string();
let name = name.to_string();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
let fetched = rt.block_on(async {
match nip05::fetch_profile(&server, &name).await {
Some(Some(hash)) => match nip05::fetch_avatar(&server, &hash).await {
Some(png) => Fetched::Found(hash, png),
None => Fetched::Failed,
},
Some(None) => Fetched::Absent,
None => Fetched::Failed,
}
});
let _ = tx.send((name, fetched));
});
}
}
+363
View File
@@ -0,0 +1,363 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Activity model: wallet transactions joined with nostr metadata.
use grin_wallet_libwallet::TxLogEntryType;
use crate::nostr::{Contact, NostrSendStatus, NostrStore, TxNostrMeta};
use crate::wallet::Wallet;
use crate::wallet::types::WalletTx;
/// A unified activity entry for the Goblin feed.
pub struct ActivityItem {
pub tx_id: u32,
pub title: String,
pub note: Option<String>,
pub amount: u64,
pub incoming: bool,
pub confirmed: bool,
/// Canceled/expired before completing (wallet-cancelled tx or expired meta).
pub canceled: bool,
pub system: bool,
pub hue: usize,
pub time: i64,
/// Counterparty npub hex, when known.
pub npub: Option<String>,
}
/// Full detail for the receipt / transaction-detail screen: GRIM tx data
/// joined with the nostr counterparty + note. Mimblewimble keeps the chain
/// private, but this is a LOCAL archive (like GRIM), so we surface whatever
/// the wallet recorded plus the npub/username we exchanged with.
pub struct ReceiptDetail {
pub tx_id: u32,
pub title: String,
pub hue: usize,
pub npub: Option<String>,
pub amount: u64,
pub incoming: bool,
pub confirmed: bool,
/// Canceled/expired before completing.
pub canceled: bool,
/// Whether the counterparty has a real identity (petname / verified NIP-05)
/// rather than just a bare npub. Gates the redundant To/From name rows.
pub has_identity: bool,
/// (current confirmations, required) when still pending and computable.
pub confs: Option<(u64, u64)>,
pub time: i64,
pub note: Option<String>,
/// Network fee in atomic units (sends only; unknown for receives).
pub fee: Option<u64>,
pub slate_id: Option<String>,
}
/// Build the receipt detail for a transaction id.
pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
let data = wallet.get_data()?;
let txs = data.txs.as_ref()?;
let tx = txs.iter().find(|t| t.data.id == tx_id)?;
let incoming = matches!(
tx.data.tx_type,
TxLogEntryType::TxReceived | TxLogEntryType::ConfirmedCoinbase
);
let system = matches!(tx.data.tx_type, TxLogEntryType::ConfirmedCoinbase);
let slate_id = tx.data.tx_slate_id.map(|u| u.to_string());
let store = wallet.nostr_service().map(|s| s.store.clone());
let store_ref = store.as_deref();
let meta: Option<TxNostrMeta> = slate_id
.as_ref()
.and_then(|sid| store_ref.and_then(|s| s.tx_meta(sid)));
let (title, hue) = if system {
("Mining reward".to_string(), 5)
} else if let Some(m) = &meta {
store_ref
.map(|s| contact_title(s, &m.npub))
.unwrap_or_else(|| (short_npub(&m.npub), 0))
} else {
let label = if incoming { "Received" } else { "Sent" };
(
label.to_string(),
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
)
};
let note = meta.as_ref().and_then(|m| m.note.clone());
let time = tx
.data
.confirmation_ts
.or(Some(tx.data.creation_ts))
.map(|t| t.timestamp())
.unwrap_or(0);
// The actual network fee from the tx kernel; a receive doesn't pay one.
let fee = if incoming {
None
} else {
Some(tx.data.fee.map(|f| f.fee()).unwrap_or(0))
};
let confs = if tx.data.confirmed {
None
} else {
match tx.height {
Some(h) if h > 0 && data.info.last_confirmed_height >= h => Some((
data.info.last_confirmed_height - h + 1,
data.info.minimum_confirmations,
)),
_ => Some((0, data.info.minimum_confirmations)),
}
};
let canceled = is_canceled(tx, meta.as_ref());
let has_identity = meta
.as_ref()
.and_then(|m| store_ref.map(|s| has_real_identity(s, &m.npub)))
.unwrap_or(false);
Some(ReceiptDetail {
tx_id,
title,
hue,
npub: meta.map(|m| m.npub),
amount: tx.amount,
incoming,
confirmed: tx.data.confirmed,
canceled,
has_identity,
confs,
time,
note,
fee,
slate_id,
})
}
/// Activity entries exchanged with a single counterparty (for their profile).
pub fn history_with(wallet: &Wallet, npub: &str) -> Vec<ActivityItem> {
activity_items(wallet)
.into_iter()
.filter(|i| i.npub.as_deref() == Some(npub))
.collect()
}
/// True when a counterparty has a real, human identity (a local petname or a
/// verified NIP-05) rather than just a bare npub. Used to suppress the
/// redundant To/From name rows on the receipt when the name would just be the
/// same truncated npub shown in the "nostr" row.
pub fn has_real_identity(store: &NostrStore, npub: &str) -> bool {
store
.contact(npub)
.map(|c| {
c.petname.as_deref().map(|p| !p.is_empty()).unwrap_or(false)
|| c.nip05_verified_at.is_some()
})
.unwrap_or(false)
}
/// Whether a transaction was canceled/expired before completing: a wallet-level
/// cancel (GRIM `TxSentCancelled`/`TxReceivedCancelled`), or expired nostr
/// metadata while still unconfirmed (a late on-chain confirmation still wins).
fn is_canceled(tx: &WalletTx, meta: Option<&TxNostrMeta>) -> bool {
matches!(
tx.data.tx_type,
TxLogEntryType::TxSentCancelled | TxLogEntryType::TxReceivedCancelled
) || (!tx.data.confirmed
&& meta
.map(|m| m.status == NostrSendStatus::Cancelled)
.unwrap_or(false))
}
/// Resolve the display title for a contact npub.
pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) {
if let Some(contact) = store.contact(npub) {
(display_name(&contact), contact.hue as usize)
} else {
let hue = hue_of(&npub);
(short_npub(npub), hue)
}
}
/// Display rule: petname → @user (verified goblin.st) → user@domain → npub short.
pub fn display_name(contact: &Contact) -> String {
if let Some(petname) = &contact.petname {
if !petname.is_empty() {
return petname.clone();
}
}
if let (Some(nip05), Some(_)) = (&contact.nip05, contact.nip05_verified_at) {
if let Some((name, domain)) = nip05.split_once('@') {
if domain == crate::nostr::relays::HOME_NIP05_DOMAIN {
return format!("@{}", name);
}
return nip05.clone();
}
}
short_npub(&contact.npub)
}
/// Short npub display (npub1abcd…wxyz) from a hex pubkey.
/// Avatar hue index derived from a hex pubkey (stable per identity, spread
/// across the full color-pair palette).
pub fn hue_of(hex: &str) -> usize {
usize::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0)
% crate::gui::theme::avatar_pairs_len()
}
/// Single-line display form of a handle for narrow chips: middle-ellipsis
/// past 16 chars, keeping the tail (names often differ at the end).
pub fn short_handle(handle: &str) -> String {
let chars: Vec<char> = handle.chars().collect();
if chars.len() <= 16 {
return handle.to_string();
}
let head: String = chars[..10].iter().collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{head}{tail}")
}
pub fn short_npub(hex: &str) -> String {
use nostr_sdk::{PublicKey, ToBech32};
if let Ok(pk) = PublicKey::from_hex(hex) {
if let Ok(npub) = pk.to_bech32() {
// Standard truncation: "npub1" + 7 head chars … 6 tail chars.
if npub.len() > 18 {
return format!("{}{}", &npub[..12], &npub[npub.len() - 6..]);
}
return npub;
}
}
format!("{}", &hex[..8.min(hex.len())])
}
/// Full bech32 npub (no truncation), for the recipient picker's grey subtitle
/// where showing the complete key is more useful than repeating the truncation.
pub fn full_npub(hex: &str) -> String {
use nostr_sdk::{PublicKey, ToBech32};
PublicKey::from_hex(hex)
.ok()
.and_then(|pk| pk.to_bech32().ok())
.unwrap_or_else(|| hex.to_string())
}
/// Build the activity feed for a wallet, newest first.
pub fn activity_items(wallet: &Wallet) -> Vec<ActivityItem> {
let data = match wallet.get_data() {
Some(d) => d,
None => return vec![],
};
let txs = data.txs.unwrap_or_default();
let store = wallet.nostr_service().map(|s| s.store.clone());
let mut items: Vec<ActivityItem> = txs
.iter()
.map(|tx| build_item(tx, store.as_deref()))
.collect();
items.sort_by_key(|i| std::cmp::Reverse(i.time));
items
}
fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
let incoming = matches!(
tx.data.tx_type,
TxLogEntryType::TxReceived | TxLogEntryType::ConfirmedCoinbase
);
let system = matches!(tx.data.tx_type, TxLogEntryType::ConfirmedCoinbase);
let slate_id = tx.data.tx_slate_id.map(|u| u.to_string());
let meta: Option<TxNostrMeta> = slate_id
.as_ref()
.and_then(|sid| store.and_then(|s| s.tx_meta(sid)));
let (title, hue) = if system {
("Mining reward".to_string(), 5)
} else if let Some(meta) = &meta {
store
.map(|s| contact_title(s, &meta.npub))
.unwrap_or_else(|| (short_npub(&meta.npub), 0))
} else {
// Fall back to slatepack address counterparty or generic label.
let label = if incoming {
"Received".to_string()
} else {
"Sent".to_string()
};
(
label,
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
)
};
let note = meta.as_ref().and_then(|m| m.note.clone());
let time = tx
.data
.confirmation_ts
.or(Some(tx.data.creation_ts))
.map(|t| t.timestamp())
.unwrap_or(0);
let canceled = is_canceled(tx, meta.as_ref());
ActivityItem {
tx_id: tx.data.id,
title,
note,
amount: tx.amount,
incoming,
confirmed: tx.data.confirmed,
canceled,
system,
hue,
time,
npub: meta.map(|m| m.npub),
}
}
/// Recent unique peers for the home strip (most recent first).
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String)> {
let store = match wallet.nostr_service() {
Some(s) => s.store.clone(),
None => return vec![],
};
let mut contacts = store.all_contacts();
contacts.sort_by_key(|c| std::cmp::Reverse(c.last_paid_at.unwrap_or(c.added_at)));
contacts
.into_iter()
.take(limit)
.map(|c| (display_name(&c), c.hue as usize, c.npub))
.collect()
}
/// Local contacts whose petname / nip05 / npub contains `query` (case-
/// insensitive) — the instant, no-network half of the recipient search.
pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, usize, String)> {
let store = match wallet.nostr_service() {
Some(s) => s.store.clone(),
None => return vec![],
};
let q = query.trim().trim_start_matches('@').to_lowercase();
if q.is_empty() {
return vec![];
}
let mut hits: Vec<(String, usize, String)> = store
.all_contacts()
.into_iter()
.filter(|c| {
c.petname
.as_deref()
.map(|p| p.to_lowercase().contains(&q))
.unwrap_or(false)
|| c.nip05
.as_deref()
.map(|n| n.to_lowercase().contains(&q))
.unwrap_or(false)
|| c.npub.to_lowercase().contains(&q)
})
.map(|c| (display_name(&c), c.hue as usize, c.npub))
.collect();
hits.truncate(limit);
hits
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Deterministic gradient avatars for anonymous nostr users.
//!
//! `avatar = f(pubkey)`: a two-tone gradient tile seeded by the pubkey, with the
//! Grin mark composited on top. Same key → identical SVG on every device, so
//! there is nothing to upload, store, or sync — each surface regenerates the
//! same bytes locally. The fallback avatar for anyone with no @handle and no
//! kind-0 `picture`, instead of a meaningless lettered tile.
//!
//! Seed = the **lowercase 64-char hex pubkey** hashed as UTF-8. Keep this byte
//! identical to the shared reference port (`identicon.rs` / `avatar.ts`): same
//! SHA-256 input, f64 math, and constants — or two surfaces draw two different
//! avatars for one person. All math is f64 (f32 drifts ±1 per channel vs JS).
use nostr_sdk::{FromBech32, PublicKey};
use sha2::{Digest, Sha256};
/// The Grin nav mark in its native 61×61 coordinate space.
const GRIN_PATH: &str = "M43.341 20.2793C42.6915 18.8211 42.0862 15.94 40.4204 15.2994C38.2758 14.4747 36.9501 19.8734 36.6342 21.2375H36.3149C35.7742 18.9002 35.0485 15.5878 32.4824 14.85C31.2943 19.8399 33.7235 25.2229 35.9955 29.5411C38.4215 28.3818 39.6035 24.7512 39.8279 22.1956H40.1473L42.7023 29.8605C44.7578 29.2697 45.4729 27.2356 46.2151 25.3893C47.8084 21.4265 49.1453 16.5529 48.1317 12.295C45.0641 13.1637 44.1309 17.5503 43.341 20.2793ZM12.6813 30.4993C15.4263 29.1886 16.7325 25.0399 17.1525 22.1956H17.4719C17.7967 23.5666 18.665 27.1037 20.3781 27.3307C22.5607 27.6195 23.7051 22.7765 23.8593 21.2375H24.1787C24.8746 23.642 25.6079 26.769 28.0112 27.9443C28.8978 24.2204 27.8361 20.249 26.4744 16.7662C26.1243 15.8707 25.4054 13.4562 24.1707 13.4562C22.1478 13.4562 21.0105 18.7885 20.6656 20.2793H20.3462L17.7913 12.6144C13.297 14.7605 10.8557 26.1727 12.6813 30.4993ZM7.89066 34.3317C11.2259 48.8795 26.6098 57.1266 40.4667 50.9832C45.5099 48.7472 49.5104 44.7634 51.8169 39.7611C52.4128 38.4686 53.5834 36.1291 52.9008 34.4333C52.2212 32.7441 45.6297 35.5041 43.9827 36.225C43.7514 36.3278 43.5883 36.5411 43.5503 36.7915C43.4963 37.1457 43.5921 37.5066 43.8153 37.7874C44.0383 38.0681 44.3682 38.2431 44.7256 38.2706C45.9331 38.3635 47.4929 38.4836 47.4929 38.4836C42.4829 48.1813 28.9371 52.4692 19.3881 44.7215C17.2509 42.9877 15.3442 40.9274 14.061 38.4836C13.4404 37.3019 12.8649 35.7906 11.81 34.9797C10.7966 34.2004 9.25919 33.9335 7.89066 34.3317Z";
/// Mark spans 90% of the tile; black at 67% opacity (matches the nav styling).
const LOGO_FRAC: f64 = 0.90;
const LOGO_OPACITY: f64 = 0.67;
const GRIN_NATIVE: f64 = 61.0;
/// Standard HSL → RGB → `#rrggbb`. f64 throughout for cross-port byte-identity.
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h / 60.0;
let x = c * (1.0 - ((hp % 2.0) - 1.0).abs());
let (r, g, b) = match hp.floor() as i32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let to = |v: f64| ((v + m) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", to(r), to(g), to(b))
}
/// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical
/// lowercase hex pubkey used as the seed everywhere.
pub fn to_hex_seed(id: &str) -> String {
if let Ok(pk) = PublicKey::from_bech32(id) {
pk.to_hex()
} else {
id.to_lowercase()
}
}
/// Gradient stop colors (`#rrggbb`) + rotation angle derived from the seed `hex`.
/// Shared by the Grin-mark avatar and the bare-background variant so both draw
/// the byte-identical gradient for one key. Keep this math in lockstep with the
/// shared reference port.
fn gradient_params(hex: &str) -> (String, String, f64) {
let hash = Sha256::digest(hex.as_bytes());
let base = ((u16::from(hash[0]) << 8 | u16::from(hash[1])) as f64 / 65_535.0) * 360.0;
let offset = 40.0 + (hash[2] as f64 / 255.0) * 120.0;
let h2 = (base + offset) % 360.0;
let angle = (hash[3] as f64 / 255.0) * 360.0;
let c1 = hsl_to_rgb(base, 0.62, 0.55);
let c2 = hsl_to_rgb(h2, 0.62, 0.42);
(c1, c2, angle)
}
/// The seeded two-tone gradient WITHOUT the Grin mark — a bare background tile.
/// Used for **named** users, where the app paints the person's initial on top
/// (see `widgets::gradient_letter_avatar`) instead of the Grin mark. Same seed →
/// same background as the anonymous gradient avatar, so one key reads consistently.
pub fn gradient_bg_svg(hex: &str, size: u32) -> String {
let (c1, c2, angle) = gradient_params(hex);
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}" role="img"><defs><linearGradient id="g" gradientUnits="objectBoundingBox" gradientTransform="rotate({angle:.1},0.5,0.5)"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs><rect width="{size}" height="{size}" fill="url(#g)"/></svg>"##
)
}
/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
/// are inlined into ONE html document; for a standalone document (how egui
/// rasterizes each one) `""` is fine.
pub fn gradient_avatar_svg(hex: &str, size: u32, id_suffix: &str) -> String {
let (c1, c2, angle) = gradient_params(hex);
let target = size as f64 * LOGO_FRAC;
let scale = target / GRIN_NATIVE;
let off = (size as f64 - target) / 2.0;
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}" role="img"><defs><linearGradient id="g{id_suffix}" gradientUnits="objectBoundingBox" gradientTransform="rotate({angle:.1},0.5,0.5)"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs><rect width="{size}" height="{size}" fill="url(#g{id_suffix})"/><g transform="translate({off:.2},{off:.2}) scale({scale:.4})"><path d="{GRIN_PATH}" fill="#000000" fill-opacity="{LOGO_OPACITY}"/></g></svg>"##
)
}
File diff suppressed because it is too large Load Diff
+894
View File
@@ -0,0 +1,894 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! First-run onboarding: what Goblin is → node choice → wallet create or
//! restore → optional payment-identity username. Wraps GRIM's mnemonic and
//! wallet-creation machinery without replacing it — the stock creation flow
//! stays available from the wallet list for later wallets.
use eframe::epaint::FontId;
use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2};
use grin_util::ZeroingString;
use crate::gui::icons::ARROW_LEFT;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::theme::{self, fonts};
use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult};
use crate::gui::views::wallets::creation::MnemonicSetup;
use crate::gui::views::{CameraScanContent, Content, Modal, TextEdit, View};
use crate::node::Node;
use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize};
use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList};
use super::widgets::{self as w};
use super::{ClaimMsg, ClaimState, start_claim_flow};
/// Identifier for the recovery-phrase QR scan [`Modal`].
const OB_PHRASE_SCAN_MODAL: &'static str = "ob_phrase_scan_modal";
/// Onboarding step.
#[derive(PartialEq, Eq, Clone, Copy)]
enum Step {
Intro,
Node,
WalletSetup,
Words,
ConfirmWords,
Identity,
}
/// First-run onboarding content.
pub struct OnboardingContent {
step: Step,
/// Node choice: integrated (own node) or external URL.
integrated: bool,
ext_url: String,
/// Wallet setup inputs.
restore: bool,
name: String,
pass: String,
pass2: String,
/// GRIM's mnemonic machinery (word grid, validation, import).
mnemonic_setup: MnemonicSetup,
/// Wallet creation error, if any.
error: Option<String>,
/// QR scanner for recovery phrase import.
scan_modal: Option<CameraScanContent>,
/// Created and opened wallet, present from the Identity step on.
wallet: Option<Wallet>,
/// Optional username claim state (same machinery as Settings).
claim: ClaimState,
}
impl Default for OnboardingContent {
fn default() -> Self {
Self {
step: Step::Intro,
integrated: true,
ext_url: "https://grincoin.org".to_string(),
restore: false,
name: "Main wallet".to_string(),
pass: String::new(),
pass2: String::new(),
mnemonic_setup: MnemonicSetup::default(),
error: None,
scan_modal: None,
wallet: None,
claim: ClaimState::default(),
}
}
}
impl OnboardingContent {
/// Render onboarding. Returns the wallet once the user finishes the
/// final step, so the host can select it and drop this content.
pub fn ui(
&mut self,
ui: &mut egui::Ui,
wallets: &mut WalletList,
cb: &dyn PlatformCallbacks,
) -> Option<Wallet> {
// Draw owned modals (word input, phrase scan) when opened.
if let Some(id) = Modal::opened() {
if id == OB_PHRASE_SCAN_MODAL {
Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
self.scan_modal_ui(ui, modal, cb);
});
} else if self.mnemonic_setup.modal_ids().contains(&id) {
Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
self.mnemonic_setup.modal_ui(ui, modal, cb);
});
}
}
let mut done = None;
ScrollArea::vertical()
.id_salt("goblin_onboarding")
.auto_shrink([false; 2])
.show(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
ui.add_space(View::get_top_inset() + 24.0);
match self.step {
Step::Intro => self.intro_ui(ui),
Step::Node => self.node_ui(ui, cb),
Step::WalletSetup => self.wallet_setup_ui(ui, cb),
Step::Words => self.words_ui(ui, wallets, cb),
Step::ConfirmWords => self.confirm_ui(ui, wallets, cb),
Step::Identity => done = self.identity_ui(ui, cb),
}
ui.add_space(View::get_bottom_inset() + 24.0);
});
});
done
}
/// Back chip + step kicker shared by all steps after the intro.
fn step_header(&mut self, ui: &mut egui::Ui, kicker: &str, title: &str, back: Step) {
let t = theme::tokens();
ui.horizontal(|ui| {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, t.surface2);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
ARROW_LEFT,
FontId::new(16.0, fonts::regular()),
t.surface_text,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.error = None;
self.step = back;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(kicker)
.font(fonts::kicker())
.color(t.text_mute),
);
});
});
ui.add_space(18.0);
ui.label(
RichText::new(title)
.font(FontId::new(26.0, fonts::bold()))
.color(t.text),
);
ui.add_space(14.0);
}
// ── Intro ────────────────────────────────────────────────────────────
fn intro_ui(&mut self, ui: &mut egui::Ui) {
let t = theme::tokens();
ui.add_space(26.0);
ui.vertical_centered(|ui| {
super::widgets_logo_sized(ui, 72.0);
ui.add_space(14.0);
ui.label(
RichText::new("goblin")
.font(FontId::new(34.0, fonts::bold()))
.color(t.text),
);
});
ui.add_space(26.0);
let lines: [(&str, &str); 3] = [
(
"Private money",
"Goblin is a wallet for grin — digital cash with no amounts \
or addresses on its chain.",
),
(
"Send like a message",
"Pay a @username or npub and it arrives as an end-to-end \
encrypted message over nostr and the Nym mixnet — no one in \
between can see the amount or who's involved.",
),
(
"Yours alone",
"Keys, names and history live on this device. Built on the \
GRIM wallet.",
),
];
for (head, body) in lines {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(head)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(body)
.font(FontId::new(13.5, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.add_space(10.0);
}
ui.add_space(16.0);
if w::big_action(ui, "Get started", false).clicked() {
self.step = Step::Node;
}
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("Takes about a minute. You can change everything later.")
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_mute),
);
});
}
// ── Node choice ──────────────────────────────────────────────────────
fn node_card(ui: &mut egui::Ui, selected: bool, title: &str, word: &str, body: &str) -> bool {
let t = theme::tokens();
let resp = ui
.scope(|ui| {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
let (dot, _) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::hover());
ui.painter().circle_stroke(
dot.center(),
8.0,
eframe::epaint::Stroke::new(1.5, t.surface_text_mute),
);
if selected {
ui.painter().circle_filled(dot.center(), 5.0, t.accent);
}
ui.add_space(8.0);
ui.label(
RichText::new(title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let galley = ui.painter().layout_no_wrap(
word.to_string(),
FontId::new(12.0, fonts::semibold()),
t.bg,
);
let pad = Vec2::new(10.0, 5.0);
let (rect, _) =
ui.allocate_exact_size(galley.size() + pad * 2.0, Sense::hover());
ui.painter().rect_filled(
rect,
eframe::epaint::CornerRadius::same(10),
t.accent,
);
ui.painter().galley(rect.min + pad, galley, t.bg);
});
});
ui.add_space(6.0);
ui.label(
RichText::new(body)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
})
.response;
resp.interact(Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
}
fn node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
self.step_header(
ui,
"STEP 1 OF 3 · NETWORK",
"How should Goblin\nwatch the chain?",
Step::Intro,
);
if Self::node_card(
ui,
self.integrated,
"Run my own node",
"Private",
"Trusts no one — your wallet checks the chain itself. Syncs in \
the background while you finish setup.",
) {
self.integrated = true;
}
ui.add_space(10.0);
if Self::node_card(
ui,
!self.integrated,
"Connect to a node",
"Instant",
"No sync wait. The node you pick can see your wallet's queries.",
) {
self.integrated = false;
}
if !self.integrated {
ui.add_space(10.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("onb_ext_url"))
.focus(false)
.hint_text("https://node.example.com")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.ext_url, cb);
});
}
ui.add_space(8.0);
ui.label(
RichText::new("Changeable any time in Settings → Node.")
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_mute),
);
ui.add_space(16.0);
let url_ok = self.integrated
|| self.ext_url.trim().starts_with("http://")
|| self.ext_url.trim().starts_with("https://");
if w::big_action(ui, "Continue", false).clicked() && url_ok {
self.step = Step::WalletSetup;
}
if !url_ok {
ui.add_space(8.0);
ui.label(
RichText::new("Node URL must start with http:// or https://")
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
}
}
// ── Wallet name + password, create vs restore ───────────────────────
fn wallet_setup_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Set up your wallet", Step::Node);
// Create / Restore segmented choice.
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
for (restore, label) in [(false, "Create new"), (true, "Restore from seed")] {
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
let active = self.restore == restore;
let resp = w::chip(ui, label, active);
if resp.clicked() {
self.restore = restore;
}
},
);
ui.add_space(10.0);
}
});
ui.add_space(14.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("onb_name"))
.focus(false)
.hint_text("Wallet name")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.name, cb);
});
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("onb_pass"))
.focus(false)
.hint_text("Password")
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.pass, cb);
});
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("onb_pass2"))
.focus(false)
.hint_text("Repeat password")
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.pass2, cb);
});
ui.add_space(10.0);
ui.label(
RichText::new(if self.restore {
"Have your seed words ready — you'll enter them next."
} else {
"Next you'll get 24 seed words to write down. They are the \
money — anyone holding them holds your funds."
})
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_mute),
);
ui.add_space(16.0);
let pass_ok = !self.pass.is_empty() && self.pass == self.pass2;
let name_ok = !self.name.trim().is_empty();
if w::big_action(ui, "Continue", false).clicked() && pass_ok && name_ok {
self.mnemonic_setup.reset();
self.mnemonic_setup.mnemonic.set_mode(if self.restore {
PhraseMode::Import
} else {
PhraseMode::Generate
});
self.mnemonic_setup.mnemonic.set_size(PhraseSize::Words24);
self.error = None;
self.step = Step::Words;
}
if !self.pass.is_empty() && self.pass != self.pass2 {
ui.add_space(8.0);
ui.label(
RichText::new("Passwords don't match")
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
}
}
// ── Seed words (display for create, entry for restore) ──────────────
fn words_ui(
&mut self,
ui: &mut egui::Ui,
wallets: &mut WalletList,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
let restore = self.mnemonic_setup.mnemonic.mode() == PhraseMode::Import;
self.step_header(
ui,
"STEP 2 OF 3 · WALLET",
if restore {
"Enter your seed words"
} else {
"Write these words down"
},
Step::WalletSetup,
);
if restore {
// Word count picker for restores.
ui.horizontal(|ui| {
for size in PhraseSize::VALUES {
let label = format!("{}", size.value());
let active = self.mnemonic_setup.mnemonic.size() == size;
if w::chip(ui, &label, active).clicked() {
self.mnemonic_setup.mnemonic.set_size(size);
}
ui.add_space(6.0);
}
});
ui.add_space(10.0);
} else {
ui.label(
RichText::new(
"On paper, in order. Anyone with these words can take \
your funds; without them a lost device means lost funds.",
)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
}
// GRIM's word grid (edit mode when restoring).
self.mnemonic_setup.word_list_ui(ui, restore);
ui.add_space(14.0);
if restore {
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::chip(ui, "Paste", false).clicked() {
let data = ZeroingString::from(cb.get_string_from_buffer());
self.mnemonic_setup.mnemonic.import(&data);
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::chip(ui, "Scan QR", false).clicked() {
self.scan_modal = Some(CameraScanContent::default());
Modal::new(OB_PHRASE_SCAN_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("scan_qr"))
.closeable(false)
.show();
cb.start_camera();
}
},
);
});
ui.add_space(14.0);
} else if w::chip(ui, "Copy to clipboard (avoid this)", false).clicked() {
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
}
if !restore {
ui.add_space(14.0);
}
let ready = if restore {
!self.mnemonic_setup.mnemonic.has_empty_or_invalid()
} else {
true
};
let label = if restore {
"Restore wallet"
} else {
"I wrote them down"
};
if ready {
if w::big_action(ui, label, false).clicked() {
if restore {
self.create_wallet(wallets);
} else {
self.step = Step::ConfirmWords;
}
}
} else {
ui.label(
RichText::new("Fill every word — tap a word to edit it, or paste the phrase.")
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_mute),
);
}
self.error_ui(ui);
}
fn confirm_ui(
&mut self,
ui: &mut egui::Ui,
wallets: &mut WalletList,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Now prove it", Step::Words);
ui.label(
RichText::new("Enter the words you just wrote down. Tap a word to type it.")
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
self.mnemonic_setup.word_list_ui(ui, true);
ui.add_space(14.0);
if w::chip(ui, "Paste", false).clicked() {
let data = ZeroingString::from(cb.get_string_from_buffer());
self.mnemonic_setup.mnemonic.import(&data);
}
ui.add_space(14.0);
if !self.mnemonic_setup.mnemonic.has_empty_or_invalid() {
if w::big_action(ui, "Create wallet", false).clicked() {
self.create_wallet(wallets);
}
} else {
ui.label(
RichText::new("Keep going — every word, in order.")
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_mute),
);
}
self.error_ui(ui);
}
fn error_ui(&self, ui: &mut egui::Ui) {
if let Some(err) = &self.error {
ui.add_space(10.0);
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(theme::tokens().neg),
);
}
}
/// Resolve the connection method, create the wallet, open it and move
/// to the identity step.
fn create_wallet(&mut self, wallets: &mut WalletList) {
// Connection: integrated starts the local node; external reuses an
// existing saved connection with the same URL or saves a new one.
let method = if self.integrated {
if !Node::is_running() {
Node::start();
}
ConnectionMethod::Integrated
} else {
let url = self.ext_url.trim().trim_end_matches('/').to_string();
let existing = ConnectionsConfig::ext_conn_list()
.into_iter()
.find(|c| c.url.trim_end_matches('/') == url);
let conn = match existing {
Some(c) => c,
None => {
let c = ExternalConnection::new(url, None, None);
ConnectionsConfig::add_ext_conn(c.clone());
c
}
};
ConnectionMethod::External(conn.id, conn.url.clone())
};
let pass = ZeroingString::from(self.pass.clone());
match Wallet::create(
&self.name.trim().to_string(),
&pass,
&self.mnemonic_setup.mnemonic,
&method,
) {
Ok(w) => {
self.mnemonic_setup.reset();
wallets.add(w.clone());
match w.open(pass) {
Ok(_) => {
self.wallet = Some(w);
self.error = None;
self.step = Step::Identity;
}
Err(e) => self.error = Some(format!("Couldn't open the wallet: {:?}", e)),
}
}
Err(e) => self.error = Some(format!("Couldn't create the wallet: {:?}", e)),
}
}
// ── Identity (optional username) ─────────────────────────────────────
fn identity_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) -> Option<Wallet> {
let t = theme::tokens();
// No back from here: the wallet exists now.
ui.label(
RichText::new("STEP 3 OF 3 · IDENTITY")
.font(fonts::kicker())
.color(t.text_mute),
);
ui.add_space(18.0);
ui.label(
RichText::new("Your payment identity")
.font(FontId::new(26.0, fonts::bold()))
.color(t.text),
);
ui.add_space(14.0);
let wallet = self.wallet.clone()?;
let service = wallet.nostr_service();
let (npub, connected) = service
.as_ref()
.map(|s| (s.npub(), s.is_connected()))
.unwrap_or((String::new(), false));
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
// Same deterministic gradient + Grin mark the rest of the app shows
// for this key; only fall back to a placeholder while the key is
// still being generated (npub not yet available).
if npub.is_empty() {
w::avatar(ui, "N", 44.0, 6);
} else {
w::gradient_avatar(ui, &npub, 44.0);
}
ui.add_space(10.0);
ui.vertical(|ui| {
let short = if npub.len() > 20 {
format!("{}{}", &npub[..12], &npub[npub.len() - 6..])
} else if npub.is_empty() {
"key being made…".to_string()
} else {
npub.clone()
};
ui.label(
RichText::new(short)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.label(
RichText::new(if connected {
"connected over Nym"
} else {
"connecting over Nym…"
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
});
});
ui.add_space(8.0);
ui.label(
RichText::new(
"A fresh key, made for payments — deliberately not part \
of your seed, so you can rotate it anytime to maintain \
your privacy, without ever touching your funds. Back it \
up in Settings → Identity.",
)
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"Want a clean slate? Swap in a brand-new key any time — \
the new you isn't linked to the old one. Same wallet, \
fresh face.",
)
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.add_space(14.0);
// Optional username claim — the same machinery as Settings.
if let Some(msg) = self.claim.result.lock().unwrap().take() {
self.claim.checking = false;
match msg {
ClaimMsg::Availability(avail) => {
let (available, msg) = super::availability_feedback(avail);
self.claim.available = available;
self.claim.message = Some(msg.to_string());
}
ClaimMsg::Registered(nip05) => {
self.claim.message =
Some(format!("You're @{}", nip05.split('@').next().unwrap_or("")));
self.claim.available = Some(true);
if let Some(s) = wallet.nostr_service() {
{
let mut id = s.identity.write();
id.nip05 = Some(nip05.clone());
id.anonymous = false;
}
s.save_identity();
}
}
ClaimMsg::Released => {}
ClaimMsg::Error(e) => {
self.claim.available = Some(false);
self.claim.message = Some(e);
}
}
}
let registered = wallet
.nostr_service()
.map(|s| s.identity.read().nip05.is_some())
.unwrap_or(false);
if !registered {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new("Pick a username — optional")
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(
"Friends pay @you instead of a long key. Public on \
goblin.st; payments stay encrypted. Skip it and \
you're simply anonymous — claim one any time later.",
)
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
w::field_well(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new("@")
.font(FontId::new(16.0, fonts::semibold()))
.color(t.surface_text),
);
let before = self.claim.input.clone();
TextEdit::new(egui::Id::from("onb_claim"))
.focus(false)
.hint_text("yourname")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.claim.input, cb);
if self.claim.input != before {
self.claim.available = None;
self.claim.message = None;
}
});
});
if let Some(msg) = &self.claim.message {
ui.add_space(6.0);
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(match self.claim.available {
Some(false) => t.neg,
Some(true) => t.pos,
None => t.surface_text_dim,
}),
);
}
ui.add_space(10.0);
let name = self.claim.input.trim().to_lowercase();
let valid = name.len() >= 3 && name.len() <= 30;
if self.claim.checking {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(RichText::new("Working…").color(t.surface_text_dim));
});
ui.ctx().request_repaint();
} else {
ui.add_enabled_ui(valid && connected, |ui| {
if w::big_action_on_card(ui, "Claim username").clicked() {
start_claim_flow(&mut self.claim, &name, &wallet);
}
});
if !connected {
ui.add_space(6.0);
ui.label(
RichText::new(
"Available once the mixnet connects — or skip and claim later.",
)
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
}
}
});
ui.add_space(16.0);
} else {
ui.add_space(2.0);
}
if !connected {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(500));
}
let main_label = if registered {
"Open my wallet"
} else {
"Skip for now"
};
if w::big_action(ui, main_label, false).clicked() {
return Some(wallet);
}
None
}
/// Recovery-phrase QR scan modal content.
fn scan_modal_ui(&mut self, ui: &mut egui::Ui, _: &Modal, cb: &dyn PlatformCallbacks) {
if let Some(content) = self.scan_modal.as_mut() {
content.modal_ui(ui, cb, |result| match result {
QrScanResult::Text(text) => {
self.mnemonic_setup.mnemonic.import(&text);
Modal::close();
}
QrScanResult::SeedQR(text) => {
self.mnemonic_setup.mnemonic.import(&text);
Modal::close();
}
_ => {}
});
}
}
}
File diff suppressed because it is too large Load Diff
+927
View File
@@ -0,0 +1,927 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Reusable Goblin design widgets: avatars, amounts, buttons, rows, chips.
use eframe::epaint::{CornerRadius, FontId, Stroke};
use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, Vec2};
use crate::gui::theme::{self, fonts};
/// Currency mark for grin amounts.
pub const TSU: &str = "";
/// Format atomic grin units to a trimmed human string (no unit).
pub fn amount_str(atomic: u64) -> String {
grin_core::core::amount_to_hr_string(atomic, true)
}
/// Draw a colored avatar puck with the contact initial.
pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let (bg, ink) = theme::avatar_pair(hue);
ui.painter().circle_filled(rect.center(), size / 2.0, bg);
// First letter of the name — never the @ prefix or other decoration.
let initial = name
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
initial,
FontId::new(size * 0.42, fonts::bold()),
ink,
);
resp
}
/// A custom-picture avatar: the texture drawn in a circle.
pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let rounding = eframe::epaint::CornerRadius::same((size / 2.0) as u8);
egui::Image::new(tex)
.corner_radius(rounding)
.fit_to_exact_size(Vec2::splat(size))
.paint_at(ui, rect);
resp
}
/// Deterministic gradient avatar (a pubkey-seeded two-tone tile with the Grin
/// mark on top) — the fallback for anonymous nostr users. `id` is the npub or
/// hex pubkey; the image is a pure function of it, so the same key always draws
/// the same avatar (see [`super::identicon`]). Cached per-pubkey by egui.
pub fn gradient_avatar(ui: &mut Ui, id: &str, size: f32) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let hex = super::identicon::to_hex_seed(id);
// Rasterize at 2x for crispness; egui caches the texture by the `uri`, so the
// SVG is generated/rasterized once per pubkey regardless of frames or size.
let svg = super::identicon::gradient_avatar_svg(&hex, (size * 2.0) as u32, "");
let uri = format!("bytes://gobavatar-{}-{}.svg", hex, size as u32);
egui::Image::new(egui::ImageSource::Bytes {
uri: uri.into(),
bytes: svg.into_bytes().into(),
})
.corner_radius(CornerRadius::same((size / 2.0) as u8))
.fit_to_exact_size(Vec2::splat(size))
.paint_at(ui, rect);
resp
}
/// A named user's avatar: the same pubkey-seeded gradient background as
/// [`gradient_avatar`], but with the person's initial painted on top (white with
/// a faint dark shadow for legibility on any hue) instead of the Grin mark. `id`
/// seeds the gradient; `name` supplies the letter.
pub fn gradient_letter_avatar(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let hex = super::identicon::to_hex_seed(id);
let svg = super::identicon::gradient_bg_svg(&hex, (size * 2.0) as u32);
let uri = format!("bytes://gobavatarbg-{}-{}.svg", hex, size as u32);
egui::Image::new(egui::ImageSource::Bytes {
uri: uri.into(),
bytes: svg.into_bytes().into(),
})
.corner_radius(CornerRadius::same((size / 2.0) as u8))
.fit_to_exact_size(Vec2::splat(size))
.paint_at(ui, rect);
// Initial — first alphanumeric of the name, never the @ prefix.
let initial = name
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
let font = FontId::new(size * 0.46, fonts::bold());
let c = rect.center();
ui.painter().text(
c + Vec2::splat(size * 0.03),
egui::Align2::CENTER_CENTER,
&initial,
font.clone(),
Color32::from_black_alpha(80),
);
ui.painter().text(
c,
egui::Align2::CENTER_CENTER,
&initial,
font,
Color32::from_rgb(0xFA, 0xFA, 0xF7),
);
resp
}
/// Picture avatar when a texture exists; otherwise the deterministic
/// pubkey-seeded gradient: with the Grin mark for an anonymous key (display name
/// is an `npub…`), or with the person's initial for a named contact/@handle. A
/// flat lettered tile is the last resort when no pubkey is known. `id` is the
/// npub/hex used to seed the gradient.
pub fn avatar_any(
ui: &mut Ui,
name: &str,
id: &str,
size: f32,
hue: usize,
tex: Option<&egui::TextureHandle>,
) -> Response {
match tex {
Some(t) => avatar_tex(ui, t, size),
None if name.starts_with("npub") && !id.is_empty() => gradient_avatar(ui, id, size),
None if !id.is_empty() => gradient_letter_avatar(ui, id, name, size),
None => avatar(ui, name, size, hue),
}
}
/// Draw a balance/amount: big bold number + smaller ツ mark, tight.
/// Geist (sans) per the design; mono is reserved for kernel/block ids.
pub fn amount_text(ui: &mut Ui, value: &str, size: f32) {
let t = theme::tokens();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label(
RichText::new(value)
.font(FontId::new(size, fonts::bold()))
.color(t.text),
);
ui.add_space(1.0);
ui.label(
RichText::new(TSU)
.font(FontId::new(size * 0.4, fonts::medium()))
.color(t.text_dim),
);
});
}
/// Like [`amount_text`] but centered in the available width.
pub fn amount_text_centered(ui: &mut Ui, value: &str, size: f32) {
let t = theme::tokens();
amount_text_centered_ink(ui, value, size, t.text, t.text_dim);
}
/// Centered amount with explicit inks, for drawing on card surfaces.
pub fn amount_text_centered_ink(
ui: &mut Ui,
value: &str,
size: f32,
num_ink: Color32,
mark_ink: Color32,
) {
amount_text_centered_shifted(ui, value, size, num_ink, mark_ink, 0.0);
}
/// Like [`amount_text_centered_ink`] but nudged horizontally by `dx` pixels — the
/// hook for the "can't pay that" shake on the Pay screen.
pub fn amount_text_centered_shifted(
ui: &mut Ui,
value: &str,
size: f32,
num_ink: Color32,
mark_ink: Color32,
dx: f32,
) {
let avail = ui.available_width();
let measure = |ui: &Ui, sz: f32| -> f32 {
let num =
ui.painter()
.layout_no_wrap(value.to_string(), FontId::new(sz, fonts::bold()), num_ink);
let mark = ui.painter().layout_no_wrap(
TSU.to_string(),
FontId::new(sz * 0.4, fonts::medium()),
mark_ink,
);
num.size().x + 1.0 + mark.size().x
};
// Shrink to fit: a long balance (e.g. 0.46520721ツ) must not run off the
// edge. Glyph width is ~linear in font size, so scale down to the available
// width with a small margin and a sane floor.
let mut size = size;
let total0 = measure(ui, size);
if total0 > avail && total0 > 1.0 {
size = (size * (avail / total0) * 0.97).clamp(14.0, size);
}
let total = measure(ui, size);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_space(((ui.available_width() - total) / 2.0 + dx).max(0.0));
ui.label(
RichText::new(value)
.font(FontId::new(size, fonts::bold()))
.color(num_ink),
);
ui.add_space(1.0);
ui.label(
RichText::new(TSU)
.font(FontId::new(size * 0.4, fonts::medium()))
.color(mark_ink),
);
});
}
/// An uppercase letterspaced kicker label.
pub fn kicker(ui: &mut Ui, text: &str) {
let t = theme::tokens();
ui.label(
RichText::new(text.to_uppercase())
.font(fonts::kicker())
.color(t.text_mute),
);
}
/// A Cash-App-style on/off switch. Yellow (brand accent) when on, neutral track
/// when off. Returns the response — the caller flips the bound state on click.
pub fn toggle(ui: &mut Ui, on: bool) -> Response {
let t = theme::tokens();
let (rect, resp) = ui.allocate_exact_size(Vec2::new(46.0, 28.0), Sense::click());
let track = if on { t.accent } else { t.surface2 };
ui.painter()
.rect_filled(rect, CornerRadius::same(14), track);
let knob_r = 11.0;
let knob_x = if on {
rect.right() - knob_r - 3.0
} else {
rect.left() + knob_r + 3.0
};
let knob = if on {
t.accent_ink
} else {
t.surface_text_mute
};
ui.painter()
.circle_filled(egui::pos2(knob_x, rect.center().y), knob_r, knob);
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A segmented control (e.g. `["Scan", "My Code"]`). Highlights `selected`;
/// returns `Some(i)` when a different segment is tapped.
pub fn segmented(ui: &mut Ui, labels: &[&str], selected: usize) -> Option<usize> {
let t = theme::tokens();
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::hover());
ui.painter()
.rect_filled(rect, CornerRadius::same(22), t.surface2);
let inner = rect.shrink(4.0);
let seg_w = inner.width() / labels.len().max(1) as f32;
let mut clicked = None;
for (i, label) in labels.iter().enumerate() {
let seg = egui::Rect::from_min_size(
inner.min + Vec2::new(i as f32 * seg_w, 0.0),
Vec2::new(seg_w, inner.height()),
);
let resp = ui.interact(seg, ui.id().with(("seg", i)), Sense::click());
let on = i == selected;
if on {
ui.painter()
.rect_filled(seg, CornerRadius::same(18), t.accent);
}
ui.painter().text(
seg.center(),
egui::Align2::CENTER_CENTER,
*label,
FontId::new(
15.0,
if on {
fonts::semibold()
} else {
fonts::regular()
},
),
if on { t.accent_ink } else { t.surface_text_dim },
);
if resp.clicked() && !on {
clicked = Some(i);
}
resp.on_hover_cursor(egui::CursorIcon::PointingHand);
}
clicked
}
/// Big primary/secondary action button (56px, radius 14).
pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response {
let t = theme::tokens();
let desired = Vec2::new(ui.available_width(), 56.0);
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
let (fill, ink, stroke) = if secondary {
(Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line))
} else {
(t.accent, t.accent_ink, Stroke::NONE)
};
let visual_fill = if resp.hovered() && !secondary {
t.accent_dark
} else {
fill
};
ui.painter().rect(
rect,
CornerRadius::same(14),
visual_fill,
stroke,
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(17.0, fonts::semibold()),
ink,
);
resp
}
/// Secondary big action drawn on a card surface: same shape as
/// [`big_action`], but the label uses on-surface text so it stays readable
/// on the yellow theme's dark cards.
pub fn big_action_on_card(ui: &mut Ui, label: &str) -> Response {
let t = theme::tokens();
let desired = Vec2::new(ui.available_width(), 56.0);
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
ui.painter().rect(
rect,
CornerRadius::same(14),
Color32::TRANSPARENT,
Stroke::new(1.5, t.line),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(17.0, fonts::semibold()),
t.surface_text,
);
resp
}
/// Like [`big_action_on_card`] with an explicit label ink (danger actions).
pub fn big_action_on_card_ink(ui: &mut Ui, label: &str, ink: Color32) -> Response {
let t = theme::tokens();
let desired = Vec2::new(ui.available_width(), 44.0);
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
ui.painter().rect(
rect,
CornerRadius::same(14),
Color32::TRANSPARENT,
Stroke::new(1.5, t.line),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(15.0, fonts::semibold()),
ink,
);
resp
}
/// A pill/chip; returns the click response. `active` paints it inverted.
pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
let t = theme::tokens();
let galley = ui.painter().layout_no_wrap(
label.to_string(),
FontId::new(13.0, fonts::semibold()),
if active { t.bg } else { t.surface_text },
);
let pad = Vec2::new(14.0, 8.0);
let size = galley.size() + pad * 2.0;
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if active { t.text } else { t.surface2 };
ui.painter().rect(
rect,
CornerRadius::same(255),
fill,
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().galley(
rect.center() - galley.size() / 2.0,
galley,
if active { t.bg } else { t.surface_text },
);
resp
}
/// An outline pill chip (transparent fill, line border) per the design's
/// amount quick-select row.
pub fn chip_outline(ui: &mut Ui, label: &str) -> Response {
let t = theme::tokens();
let galley = ui.painter().layout_no_wrap(
label.to_string(),
FontId::new(13.0, fonts::semibold()),
t.text,
);
let pad = Vec2::new(14.0, 8.0);
let size = galley.size() + pad * 2.0;
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
ui.painter().rect(
rect,
CornerRadius::same(255),
Color32::TRANSPARENT,
Stroke::new(1.0, t.line),
egui::StrokeKind::Inside,
);
ui.painter()
.galley(rect.center() - galley.size() / 2.0, galley, t.text);
resp
}
/// Paint a QR code for `text` with the goblin mark centered, per the
/// design's receive card. Always dark modules on a white plate, whatever the
/// theme: inverted (light-on-dark) codes fail to decode in a number of
/// scanner apps. Encoding a short URI is microseconds, so this is done
/// synchronously each frame; modules are plain painter rects.
pub fn qr_code(ui: &mut Ui, text: &str, size: f32) {
let plate = Color32::WHITE;
let ink = Color32::from_rgb(0x0E, 0x0E, 0x0C);
// High error correction tolerates the center mark covering modules.
let Ok(qr) = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::High) else {
return;
};
let pad = (size * 0.05).max(8.0);
let (outer, _) = ui.allocate_exact_size(Vec2::splat(size + pad * 2.0), Sense::hover());
ui.painter()
.rect_filled(outer, CornerRadius::same(16), plate);
let rect = outer.shrink(pad);
let n = qr.size();
let cell = size / n as f32;
// Full cells with no inter-module gap: at receive-card density (~4.5px
// cells) even a 0.5px gap fragments the finder patterns and scanners
// fail to detect the code at all (probed with rqrr). Corner rounding
// only when cells are big enough that the notching can't matter.
let radius = if cell >= 6.0 { (cell * 0.3) as u8 } else { 0 };
for y in 0..n {
for x in 0..n {
if qr.get_module(x, y) {
let min = rect.min + Vec2::new(x as f32 * cell, y as f32 * cell);
ui.painter().rect_filled(
egui::Rect::from_min_size(min, Vec2::splat(cell)),
CornerRadius::same(radius),
ink,
);
}
}
}
// Goblin mark on a yellow backing square in the center, same 19% footprint
// the white version was tuned to (at 26%, zbar-class scanners fail on the
// glyph; 19% passes everything probed). Yellow's luminance reads as "light"
// to a scanner just like white, so the obscured center is recovered by the
// High ECC exactly as before — only the colour changes.
let t = theme::tokens();
let backing = size * 0.19;
let b_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing));
ui.painter()
.rect_filled(b_rect, CornerRadius::same((backing * 0.18) as u8), t.accent);
let m_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing * 0.72));
egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg"))
.tint(t.accent_ink)
.fit_to_exact_size(m_rect.size())
.paint_at(ui, m_rect);
}
/// A filled input well for a text field sitting on a card, so the field
/// reads as a field: frameless edits on the card fill are invisible.
pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
let t = theme::tokens();
egui::Frame {
fill: t.surface2,
stroke: Stroke::new(1.0, t.line),
corner_radius: CornerRadius::same(10),
inner_margin: egui::Margin::symmetric(12, 10),
..Default::default()
}
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
content(ui);
});
}
/// A balance hero block: kicker, big number + ツ, optional fiat line.
pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) {
let t = theme::tokens();
// Centered to match the Pay amount and the empty-state below it.
ui.vertical_centered(|ui| kicker(ui, "Balance"));
ui.add_space(6.0);
amount_text_centered(ui, &amount_str(atomic), size);
if let Some(fiat) = fiat {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(fiat)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
});
}
}
/// An activity row: avatar, title, subtitle, signed amount.
/// Returns the row click response.
pub fn activity_row(
ui: &mut Ui,
title: &str,
subtitle: &str,
hue: usize,
id: &str,
amount: &str,
incoming: bool,
system: bool,
tex: Option<&egui::TextureHandle>,
) -> Response {
let t = theme::tokens();
let row_h = 60.0;
let (rect, resp) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click());
let mut content = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect.shrink2(Vec2::new(0.0, 8.0)))
.layout(Layout::left_to_right(Align::Center)),
);
content.horizontal(|ui| {
if system {
let (r, _) = ui.allocate_exact_size(Vec2::splat(40.0), Sense::hover());
ui.painter().rect(
r,
CornerRadius::same(10),
t.surface2,
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::CUBE,
FontId::new(20.0, fonts::regular()),
t.text,
);
} else {
avatar_any(ui, title, id, 40.0, hue, tex);
}
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(2.0);
ui.add(
egui::Label::new(
RichText::new(title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
)
.truncate(),
);
// Single-line, truncated: keeps the fixed-height row tidy even when
// the subtitle is a long value (e.g. a full npub in the picker).
ui.add(
egui::Label::new(
RichText::new(subtitle)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
)
.truncate(),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(amount)
.font(FontId::new(15.0, fonts::mono_semibold()))
.color(if incoming { t.pos } else { t.text }),
);
});
});
// Divider.
let line_y = rect.bottom();
ui.painter()
.hline(rect.left()..=rect.right(), line_y, Stroke::new(1.0, t.line));
resp
}
/// Section header used above grouped lists.
pub fn section_header(ui: &mut Ui, text: &str) {
ui.add_space(8.0);
kicker(ui, text);
ui.add_space(6.0);
}
/// Draw a rounded surface card and run a closure inside it.
pub fn card<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
let t = theme::tokens();
egui::Frame::new()
.fill(t.surface)
.stroke(Stroke::new(1.0, t.line))
.corner_radius(CornerRadius::same(18))
.inner_margin(16.0)
.show(ui, add_contents)
.inner
}
/// A bordered rect helper for non-interactive value rows.
pub fn info_row(ui: &mut Ui, label: &str, value: &str) {
let t = theme::tokens();
ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Truncate so a long value (e.g. "Encrypted nostr DM over Nym") never
// runs past the edge or collides with the label on a narrow screen.
ui.add(
egui::Label::new(
RichText::new(value)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
)
.truncate(),
);
});
});
ui.add_space(8.0);
ui.painter().hline(
ui.min_rect().left()..=ui.min_rect().right(),
ui.cursor().top(),
Stroke::new(1.0, t.line),
);
ui.add_space(8.0);
}
/// Draw a centered Send / Receive split. Returns (send, receive) clicks.
pub fn send_receive(ui: &mut Ui) -> (bool, bool) {
let t = theme::tokens();
let mut send = false;
let mut receive = false;
let h = 60.0;
ui.horizontal(|ui| {
let w = (ui.available_width() - 10.0) / 2.0;
let (rs, resp_s) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click());
ui.painter().rect(
rs,
CornerRadius::same(14),
if resp_s.hovered() {
t.accent_dark
} else {
t.accent
},
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
rs.center(),
egui::Align2::CENTER_CENTER,
format!("{} Send", crate::gui::icons::ARROW_UP),
FontId::new(16.0, fonts::semibold()),
t.accent_ink,
);
send = resp_s.clicked();
ui.add_space(10.0);
let (rr, resp_r) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click());
let r_fill = if resp_r.hovered() {
t.hover
} else {
t.surface2
};
ui.painter().rect(
rr,
CornerRadius::same(14),
r_fill,
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
rr.center(),
egui::Align2::CENTER_CENTER,
format!("{} Receive", crate::gui::icons::ARROW_DOWN),
FontId::new(16.0, fonts::semibold()),
theme::ink_for(r_fill),
);
receive = resp_r.clicked();
});
(send, receive)
}
/// A simple numeric keypad. Mutates `amount` string. Returns true if changed.
pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
let t = theme::tokens();
let mut changed = false;
let keys = [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
[".", "0", "<"],
];
let key_h = 58.0;
let gap = 14.0;
// Center a fixed-width pad so the three columns line up directly under
// the centered amount above, on any width. Wider than before to give the
// columns more breathing room (Cash App-style).
let pad_w = ui.available_width().min(332.0);
let key_w = (pad_w - 2.0 * gap) / 3.0;
let side = ((ui.available_width() - pad_w) / 2.0).max(0.0);
// Spread the four rows toward the bottom when there's room (the Pay tab,
// which otherwise leaves a big empty gap), staying compact on dense
// screens (the send flow). Reserve space below for the action buttons and
// the floating tab bar. Clamped so it never stretches absurdly or overflows.
let reserve_below = 170.0;
let avail = (ui.available_height() - reserve_below).max(0.0);
let row_gap = ((avail - key_h * 4.0) / 3.0).clamp(6.0, 30.0);
for (ri, row) in keys.iter().enumerate() {
if ri > 0 {
ui.add_space(row_gap);
}
ui.horizontal(|ui| {
ui.add_space(side);
for (i, &k) in row.iter().enumerate() {
if i > 0 {
ui.add_space(gap);
}
let (rect, resp) = ui.allocate_exact_size(Vec2::new(key_w, key_h), Sense::click());
let label = if k == "<" {
crate::gui::icons::BACKSPACE.to_string()
} else {
k.to_string()
};
let col = if resp.hovered() { t.accent } else { t.text };
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(30.0, fonts::medium()),
col,
);
if resp.clicked() {
apply_key(amount, k);
changed = true;
}
}
});
}
changed
}
/// Apply a numpad key to the amount string with validation.
/// Apply typed keyboard events (digits, '.', backspace) to an amount string,
/// for desktop where the on-screen numpad is hidden.
pub fn amount_typed_input(ui: &Ui, amount: &mut String) {
ui.input(|i| {
for ev in &i.events {
if let egui::Event::Text(txt) = ev {
for ch in txt.chars() {
if ch.is_ascii_digit() {
apply_key(amount, &ch.to_string());
} else if ch == '.' {
apply_key(amount, ".");
}
}
}
if let egui::Event::Key {
key: egui::Key::Backspace,
pressed: true,
..
} = ev
{
apply_key(amount, "<");
}
}
});
}
pub fn apply_key(amount: &mut String, key: &str) {
match key {
"<" => {
amount.pop();
}
"." => {
if !amount.contains('.') {
if amount.is_empty() {
amount.push('0');
}
amount.push('.');
}
}
d => {
// Limit to 9 decimals (grin precision).
if let Some(dot) = amount.find('.') {
if amount.len() - dot - 1 >= 9 {
return;
}
}
// Avoid leading zeros like "00".
if amount == "0" {
amount.clear();
}
amount.push_str(d);
}
}
}
/// Paint a full-rect background fill on the current panel.
pub fn fill_bg(ui: &Ui, color: Color32) {
let rect = ui.ctx().screen_rect();
ui.painter().rect_filled(rect, CornerRadius::ZERO, color);
}
/// Center a fixed-width column for narrow content on wide screens.
/// Hands the child the full remaining height: wrapping in `horizontal()`
/// would start the row a single line tall, so a `ScrollArea` inside would
/// clip everything below the first widget.
pub fn centered_column<R>(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
// Keep a small side gutter so content sits close to the screen edges on
// phones (where `width` exceeds the available width) without running flush.
const MIN_SIDE_PAD: f32 = 8.0;
let avail = ui.available_width();
let w = width.min(avail - MIN_SIDE_PAD * 2.0).max(0.0);
let margin = ((avail - w) / 2.0).max(MIN_SIDE_PAD);
let mut rect = ui.available_rect_before_wrap();
rect.min.x += margin;
rect.max.x = rect.min.x + w;
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(Layout::top_down(Align::Min)),
);
let result = add(&mut child);
ui.allocate_rect(child.min_rect(), Sense::hover());
result
}
/// Hold-to-send button: fills over `hold_secs`; returns true once on completion.
pub struct HoldToSend {
progress: f32,
}
impl Default for HoldToSend {
fn default() -> Self {
Self { progress: 0.0 }
}
}
impl HoldToSend {
pub fn ui(&mut self, ui: &mut Ui, label: &str) -> bool {
let t = theme::tokens();
let (rect, resp) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), 56.0),
Sense::click_and_drag(),
);
// Background.
ui.painter().rect(
rect,
CornerRadius::same(14),
t.surface2,
Stroke::NONE,
egui::StrokeKind::Inside,
);
let held = resp.is_pointer_button_down_on() || resp.dragged();
let dt = ui.input(|i| i.stable_dt).min(0.1);
if held {
self.progress = (self.progress + dt / 0.7).min(1.0);
ui.ctx().request_repaint();
} else {
self.progress = (self.progress - dt / 0.3).max(0.0);
if self.progress > 0.0 {
ui.ctx().request_repaint();
}
}
// Progress fill.
if self.progress > 0.0 {
let mut fill_rect = rect;
fill_rect.set_width(rect.width() * self.progress);
ui.painter().rect(
fill_rect,
CornerRadius::same(14),
t.accent,
Stroke::NONE,
egui::StrokeKind::Inside,
);
}
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(17.0, fonts::semibold()),
if self.progress > 0.5 {
t.accent_ink
} else {
theme::ink_for(t.surface2)
},
);
if self.progress >= 1.0 {
self.progress = 0.0;
return true;
}
false
}
}
/// Shorten a long key/address for display (8…6).
pub fn short_key(key: &str) -> String {
if key.len() <= 16 {
return key.to_string();
}
format!("{}{}", &key[..8], &key[key.len() - 6..])
}
+424 -362
View File
@@ -18,436 +18,498 @@ use lazy_static::lazy_static;
use parking_lot::RwLock;
use std::sync::Arc;
use crate::gui::Colors;
use crate::gui::icons::{CLIPBOARD_TEXT, COPY, EYE, EYE_SLASH, SCAN};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::input::keyboard::KeyboardContent;
use crate::gui::views::{KeyboardEvent, View};
use crate::gui::Colors;
/// Text input content.
pub struct TextEdit {
/// View identifier.
id: egui::Id,
/// Check if input is enabled or disabled.
enabled: bool,
/// Horizontal text centering is needed.
h_center: bool,
/// Focus is needed.
focus: bool,
/// Focus request was passed.
focus_request: bool,
/// Hide letters and draw button to show/hide letters.
password: bool,
/// Show copy button.
copy: bool,
/// Show paste button.
paste: bool,
/// Show button to scan QR code into text.
scan_qr: bool,
/// Scan button was pressed.
pub scan_pressed: bool,
/// Tab or Enter keys were pressed to focus on next line.
pub enter_pressed: bool,
/// Flag to enter only numbers.
numeric: bool,
/// Flag to not show soft keyboard.
no_soft_keyboard: bool,
/// View identifier.
id: egui::Id,
/// Check if input is enabled or disabled.
enabled: bool,
/// Horizontal text centering is needed.
h_center: bool,
/// Focus is needed.
focus: bool,
/// Focus request was passed.
focus_request: bool,
/// Hide letters and draw button to show/hide letters.
password: bool,
/// Show copy button.
copy: bool,
/// Show paste button.
paste: bool,
/// Show button to scan QR code into text.
scan_qr: bool,
/// Scan button was pressed.
pub scan_pressed: bool,
/// Tab or Enter keys were pressed to focus on next line.
pub enter_pressed: bool,
/// Flag to enter only numbers.
numeric: bool,
/// Flag to not show soft keyboard.
no_soft_keyboard: bool,
/// Optional placeholder shown when empty.
hint: Option<String>,
/// Optional text color override (defaults to the theme text color).
text_color: Option<egui::Color32>,
/// Use the body text style instead of the default heading.
body_font: bool,
}
impl TextEdit {
/// Default height of [`egui::TextEdit`] view.
const TEXT_EDIT_HEIGHT: f32 = 42.0;
/// Default height of [`egui::TextEdit`] view.
const TEXT_EDIT_HEIGHT: f32 = 42.0;
pub fn new(id: egui::Id) -> Self {
Self {
id,
enabled: true,
h_center: false,
focus: true,
focus_request: false,
password: false,
copy: false,
paste: false,
scan_qr: false,
scan_pressed: false,
enter_pressed: false,
numeric: false,
no_soft_keyboard: is_android(),
}
}
pub fn new(id: egui::Id) -> Self {
Self {
id,
enabled: true,
h_center: false,
focus: true,
focus_request: false,
password: false,
copy: false,
paste: false,
scan_qr: false,
scan_pressed: false,
enter_pressed: false,
numeric: false,
// Goblin uses each platform's NATIVE input everywhere: the Android
// soft keyboard via JNI on Android, the physical keyboard on desktop.
// Upstream Grim only suppresses its own on-screen keyboard on Android
// (`is_android()`) and pops it on desktop — which looked out of place
// in Goblin's wallet flows and competed with physical typing. Suppress
// it on every platform; native text entry still works.
no_soft_keyboard: true,
hint: None,
text_color: None,
body_font: false,
}
}
/// Draw text input.
pub fn ui(&mut self, ui: &mut egui::Ui, input: &mut String, cb: &dyn PlatformCallbacks) {
self.input_ui(ui, input, |_| {}, cb);
}
/// Draw text input.
pub fn ui(&mut self, ui: &mut egui::Ui, input: &mut String, cb: &dyn PlatformCallbacks) {
self.input_ui(ui, input, |_| {}, cb);
}
/// Draw text input with additional buttons (right to left order).
pub fn custom_buttons_ui(&mut self,
ui: &mut egui::Ui,
input: &mut String,
cb: &dyn PlatformCallbacks,
buttons_content: impl FnOnce(&mut egui::Ui)) {
self.input_ui(ui, input, buttons_content, cb);
}
/// Draw text input with additional buttons (right to left order).
pub fn custom_buttons_ui(
&mut self,
ui: &mut egui::Ui,
input: &mut String,
cb: &dyn PlatformCallbacks,
buttons_content: impl FnOnce(&mut egui::Ui),
) {
self.input_ui(ui, input, buttons_content, cb);
}
/// Draw text input content.
fn input_ui(&mut self,
ui: &mut egui::Ui,
input: &mut String,
buttons_content: impl FnOnce(&mut egui::Ui),
cb: &dyn PlatformCallbacks) {
let mut layout_rect = ui.available_rect_before_wrap();
layout_rect.set_height(Self::TEXT_EDIT_HEIGHT);
ui.allocate_ui_with_layout(layout_rect.size(), Layout::right_to_left(Align::Max), |ui| {
let mut hide_input = false;
if self.password {
let show_pass_id = egui::Id::new(self.id).with("_show_pass");
hide_input = ui.data(|data| {
data.get_temp(show_pass_id)
}).unwrap_or(true);
// Draw button to show/hide current password.
let eye_icon = if hide_input { EYE } else { EYE_SLASH };
View::button_ui(ui, eye_icon.to_string(), Colors::white_or_black(false), |ui| {
hide_input = !hide_input;
ui.data_mut(|data| {
data.insert_temp(show_pass_id, hide_input);
});
});
ui.add_space(8.0);
}
/// Draw text input content.
fn input_ui(
&mut self,
ui: &mut egui::Ui,
input: &mut String,
buttons_content: impl FnOnce(&mut egui::Ui),
cb: &dyn PlatformCallbacks,
) {
let mut layout_rect = ui.available_rect_before_wrap();
layout_rect.set_height(Self::TEXT_EDIT_HEIGHT);
ui.allocate_ui_with_layout(
layout_rect.size(),
Layout::right_to_left(Align::Max),
|ui| {
let mut hide_input = false;
if self.password {
let show_pass_id = egui::Id::new(self.id).with("_show_pass");
hide_input = ui.data(|data| data.get_temp(show_pass_id)).unwrap_or(true);
// Draw button to show/hide current password.
let eye_icon = if hide_input { EYE } else { EYE_SLASH };
View::button_ui(
ui,
eye_icon.to_string(),
Colors::white_or_black(false),
|ui| {
hide_input = !hide_input;
ui.data_mut(|data| {
data.insert_temp(show_pass_id, hide_input);
});
},
);
ui.add_space(8.0);
}
// Extra buttons content.
(buttons_content)(ui);
// Extra buttons content.
(buttons_content)(ui);
// Setup copy button.
if self.copy {
let copy_icon = COPY.to_string();
View::button(ui, copy_icon, Colors::white_or_black(false), || {
cb.copy_string_to_buffer(input.clone());
});
ui.add_space(8.0);
}
// Setup copy button.
if self.copy {
let copy_icon = COPY.to_string();
View::button(ui, copy_icon, Colors::white_or_black(false), || {
cb.copy_string_to_buffer(input.clone());
});
ui.add_space(8.0);
}
// Setup paste button.
if self.paste {
let paste_icon = CLIPBOARD_TEXT.to_string();
View::button(ui, paste_icon, Colors::white_or_black(false), || {
*input = cb.get_string_from_buffer();
});
ui.add_space(8.0);
}
// Setup paste button.
if self.paste {
let paste_icon = CLIPBOARD_TEXT.to_string();
View::button(ui, paste_icon, Colors::white_or_black(false), || {
*input = cb.get_string_from_buffer();
});
ui.add_space(8.0);
}
// Setup scan QR code button.
if self.scan_qr {
let scan_icon = SCAN.to_string();
View::button(ui, scan_icon, Colors::white_or_black(false), || {
cb.start_camera();
self.scan_pressed = true;
});
ui.add_space(8.0);
}
// Setup scan QR code button.
if self.scan_qr {
let scan_icon = SCAN.to_string();
View::button(ui, scan_icon, Colors::white_or_black(false), || {
cb.start_camera();
self.scan_pressed = true;
});
ui.add_space(8.0);
}
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Min), |ui| {
// Setup text edit size.
let mut edit_rect = ui.available_rect_before_wrap();
edit_rect.set_height(Self::TEXT_EDIT_HEIGHT);
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Min), |ui| {
// Setup text edit size.
let mut edit_rect = ui.available_rect_before_wrap();
edit_rect.set_height(Self::TEXT_EDIT_HEIGHT);
// Setup focused input value to avoid dismiss when click on keyboard.
let focused_input_id = egui::Id::new("focused_input_id");
let focused = ui.data(|data| {
data.get_temp(focused_input_id)
}).unwrap_or(egui::Id::new("")) == self.id;
// Setup focused input value to avoid dismiss when click on keyboard.
let focused_input_id = egui::Id::new("focused_input_id");
let focused = ui
.data(|data| data.get_temp(focused_input_id))
.unwrap_or(egui::Id::new(""))
== self.id;
// Show text edit.
let text_edit_resp = egui::TextEdit::singleline(input)
.text_color(if self.enabled {
Colors::text(false)
} else {
Colors::inactive_text()
})
.interactive(self.enabled)
.id(self.id)
.font(TextStyle::Heading)
.min_size(edit_rect.size())
.margin(if View::is_desktop() {
egui::Margin::symmetric(4, 2)
} else {
egui::Margin::symmetric(8, 8)
})
.horizontal_align(if self.h_center { Align::Center } else { Align::Min })
.vertical_align(Align::Center)
.password(hide_input)
.cursor_at_end(true)
.ui(ui);
// Show text edit.
let text_edit_resp = egui::TextEdit::singleline(input)
.hint_text(self.hint.clone().unwrap_or_default())
.text_color(if !self.enabled {
Colors::inactive_text()
} else if let Some(c) = self.text_color {
c
} else {
Colors::text(false)
})
.interactive(self.enabled)
.id(self.id)
.font(if self.body_font {
TextStyle::Body
} else {
TextStyle::Heading
})
.min_size(edit_rect.size())
.margin(if View::is_desktop() {
egui::Margin::symmetric(4, 2)
} else {
egui::Margin::symmetric(8, 8)
})
.horizontal_align(if self.h_center {
Align::Center
} else {
Align::Min
})
.vertical_align(Align::Center)
.password(hide_input)
.cursor_at_end(true)
.ui(ui);
// Setup focus state.
let clicked = text_edit_resp.clicked();
if !text_edit_resp.has_focus() &&
(self.focus || self.focus_request || clicked || focused) {
text_edit_resp.request_focus();
}
// Setup focus state.
let clicked = text_edit_resp.clicked();
if !text_edit_resp.has_focus()
&& (self.focus || self.focus_request || clicked || focused)
{
text_edit_resp.request_focus();
}
// Reset keyboard state for newly focused.
if clicked || self.focus_request {
ui.ctx().send_viewport_cmd(ViewportCommand::IMEAllowed(true));
KeyboardContent::reset_window_state();
}
// Reset keyboard state for newly focused.
if clicked || self.focus_request {
ui.ctx()
.send_viewport_cmd(ViewportCommand::IMEAllowed(true));
KeyboardContent::reset_window_state();
}
// Apply text from software input.
if text_edit_resp.has_focus() {
ui.data_mut(|data| {
data.insert_temp(focused_input_id, self.id);
});
self.enter_pressed = self.on_soft_input(ui, self.id, false, input);
// Check Enter or Tab keys press.
if !self.focus_request {
if ui.ctx().input(|i| i.key_pressed(egui::Key::Enter) ||
i.key_pressed(egui::Key::Tab)) {
self.enter_pressed = true;
}
}
if self.enter_pressed {
KeyboardContent::unshift();
}
if !self.no_soft_keyboard {
KeyboardContent::default().window_ui(self.numeric, ui.ctx());
}
}
});
});
// Immediate repaint when input is open.
ui.ctx().request_repaint();
}
// Apply text from software input.
if text_edit_resp.has_focus() {
ui.data_mut(|data| {
data.insert_temp(focused_input_id, self.id);
});
self.enter_pressed = self.on_soft_input(ui, self.id, false, input);
// Check Enter or Tab keys press.
if !self.focus_request {
if ui.ctx().input(|i| {
i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Tab)
}) {
self.enter_pressed = true;
}
}
if self.enter_pressed {
KeyboardContent::unshift();
}
if !self.no_soft_keyboard {
KeyboardContent::default().window_ui(self.numeric, ui.ctx());
}
}
});
},
);
// Immediate repaint when input is open.
ui.ctx().request_repaint();
}
/// Apply soft keyboard input data to provided String, returns `true` if Enter was pressed.
fn on_soft_input(&self, ui: &mut egui::Ui, id: egui::Id, multiline: bool, value: &mut String)
-> bool {
let event: Option<KeyboardEvent> = if is_android() {
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
w_input.take()
} else {
KeyboardContent::consume_event()
};
/// Apply soft keyboard input data to provided String, returns `true` if Enter was pressed.
fn on_soft_input(
&self,
ui: &mut egui::Ui,
id: egui::Id,
multiline: bool,
value: &mut String,
) -> bool {
let event: Option<KeyboardEvent> = if is_android() {
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
w_input.take()
} else {
KeyboardContent::consume_event()
};
// Handle keyboard input event.
if let Some(e) = event {
let mut enter_pressed = false;
let mut state = TextEditState::load(ui.ctx(), id).unwrap();
match state.cursor.char_range() {
None => {}
Some(range) => {
let mut r = range.clone();
let mut index = r.primary.index;
// Handle keyboard input event.
if let Some(e) = event {
let mut enter_pressed = false;
let mut state = TextEditState::load(ui.ctx(), id).unwrap();
match state.cursor.char_range() {
None => {}
Some(range) => {
let mut r = range.clone();
let mut index = r.primary.index;
let selected = r.primary.index != r.secondary.index;
let start_select = f32::min(r.primary.index as f32,
r.secondary.index as f32) as usize;
let end_select = f32::max(r.primary.index as f32,
r.secondary.index as f32) as usize;
match e {
KeyboardEvent::TEXT(text) => {
if selected {
*value = {
let part1: String = value.chars()
.skip(0)
.take(start_select)
.collect();
let part2: String = value.chars()
.skip(end_select)
.take(value.len() - end_select)
.collect();
format!("{}{}{}", part1, text, part2)
};
index = start_select + 1;
} else {
value.insert_text(text.as_str(), index);
index = index + 1;
}
}
KeyboardEvent::CLEAR => {
if selected {
*value = {
let part1: String = value.chars()
.skip(0)
.take(start_select)
.collect();
let part2: String = value.chars()
.skip(end_select)
.take(value.len() - end_select)
.collect();
format!("{}{}", part1, part2)
};
index = start_select;
} else if index != 0 {
*value = {
let part1: String = value.chars()
.skip(0)
.take(index - 1)
.collect();
let part2: String = value.chars()
.skip(index)
.take(value.len() - index)
.collect();
format!("{}{}", part1, part2)
};
index = index - 1;
}
}
KeyboardEvent::ENTER => {
if multiline {
value.insert_text("\n", index);
index = index + 1;
} else {
enter_pressed = true;
}
}
}
// Setup cursor index.
r.primary.index = index;
r.secondary.index = r.primary.index;
let selected = r.primary.index != r.secondary.index;
let start_select =
f32::min(r.primary.index as f32, r.secondary.index as f32) as usize;
let end_select =
f32::max(r.primary.index as f32, r.secondary.index as f32) as usize;
match e {
KeyboardEvent::TEXT(text) => {
if selected {
*value = {
let part1: String =
value.chars().skip(0).take(start_select).collect();
let part2: String = value
.chars()
.skip(end_select)
.take(value.len() - end_select)
.collect();
format!("{}{}{}", part1, text, part2)
};
index = start_select + 1;
} else {
value.insert_text(text.as_str(), index);
index = index + 1;
}
}
KeyboardEvent::CLEAR => {
if selected {
*value = {
let part1: String =
value.chars().skip(0).take(start_select).collect();
let part2: String = value
.chars()
.skip(end_select)
.take(value.len() - end_select)
.collect();
format!("{}{}", part1, part2)
};
index = start_select;
} else if index != 0 {
*value = {
let part1: String =
value.chars().skip(0).take(index - 1).collect();
let part2: String = value
.chars()
.skip(index)
.take(value.len() - index)
.collect();
format!("{}{}", part1, part2)
};
index = index - 1;
}
}
KeyboardEvent::ENTER => {
if multiline {
value.insert_text("\n", index);
index = index + 1;
} else {
enter_pressed = true;
}
}
}
// Setup cursor index.
r.primary.index = index;
r.secondary.index = r.primary.index;
state.cursor.set_char_range(Some(r));
TextEditState::store(state, ui.ctx(), id);
}
}
return enter_pressed;
}
false
}
state.cursor.set_char_range(Some(r));
TextEditState::store(state, ui.ctx(), id);
}
}
return enter_pressed;
}
false
}
/// Set cursor to the end of text.
pub fn cursor_to_end(&self, text_len: usize, ui: &mut egui::Ui) {
let mut state = TextEditState::load(ui.ctx(), self.id).unwrap();
match state.cursor.char_range() {
None => {}
Some(range) => {
let mut r = range.clone();
r.primary.index = text_len;
r.secondary.index = text_len;
state.cursor.set_char_range(Some(r));
TextEditState::store(state, ui.ctx(), self.id);
}
}
}
/// Set cursor to the end of text.
pub fn cursor_to_end(&self, text_len: usize, ui: &mut egui::Ui) {
let mut state = TextEditState::load(ui.ctx(), self.id).unwrap();
match state.cursor.char_range() {
None => {}
Some(range) => {
let mut r = range.clone();
r.primary.index = text_len;
r.secondary.index = text_len;
state.cursor.set_char_range(Some(r));
TextEditState::store(state, ui.ctx(), self.id);
}
}
}
/// Disable input.
pub fn disable(mut self) -> Self {
self.enabled = false;
self
}
/// Disable input.
pub fn disable(mut self) -> Self {
self.enabled = false;
self
}
/// Center text horizontally.
pub fn h_center(mut self) -> Self {
self.h_center = true;
self
}
/// Center text horizontally.
pub fn h_center(mut self) -> Self {
self.h_center = true;
self
}
/// Enable or disable constant focus.
pub fn focus(mut self, focus: bool) -> Self {
self.focus = focus;
self
}
/// Enable or disable constant focus.
pub fn focus(mut self, focus: bool) -> Self {
self.focus = focus;
self
}
/// Focus on field.
pub fn focus_request(&mut self) {
self.focus_request = true;
}
/// Focus on field.
pub fn focus_request(&mut self) {
self.focus_request = true;
}
/// Allow input of numbers only.
pub fn numeric(mut self) -> Self {
self.numeric = true;
self
}
/// Allow input of numbers only.
pub fn numeric(mut self) -> Self {
self.numeric = true;
self
}
/// Hide letters and draw button to show/hide letters.
pub fn password(mut self) -> Self {
self.password = true;
self
}
/// Hide letters and draw button to show/hide letters.
pub fn password(mut self) -> Self {
self.password = true;
self
}
/// Show button to copy text.
pub fn copy(mut self) -> Self {
self.copy = true;
self
}
/// Show button to copy text.
pub fn copy(mut self) -> Self {
self.copy = true;
self
}
/// Show button to paste text.
pub fn paste(mut self) -> Self {
self.paste = true;
self
}
/// Show button to paste text.
pub fn paste(mut self) -> Self {
self.paste = true;
self
}
/// Show button to scan QR code to text.
pub fn scan_qr(mut self) -> Self {
self.scan_qr = true;
self.scan_pressed = false;
self
}
/// Show button to scan QR code to text.
pub fn scan_qr(mut self) -> Self {
self.scan_qr = true;
self.scan_pressed = false;
self
}
/// Do not show soft keyboard for input.
pub fn no_soft_keyboard(mut self) -> Self {
self.no_soft_keyboard = true;
self
}
/// Do not show soft keyboard for input.
pub fn no_soft_keyboard(mut self) -> Self {
self.no_soft_keyboard = true;
self
}
/// Set placeholder text shown when the field is empty.
pub fn hint_text(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// Override the text color (e.g. to match an on-card theme token).
pub fn text_color(mut self, color: egui::Color32) -> Self {
self.text_color = Some(color);
self
}
/// Render with the body text style instead of the default heading.
pub fn body(mut self) -> Self {
self.body_font = true;
self
}
}
/// Check if current system is Android.
fn is_android() -> bool {
egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Android
egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Android
}
lazy_static! {
static ref LAST_SOFT_KEYBOARD_EVENT: Arc<RwLock<Option<KeyboardEvent>>> = Arc::new(RwLock::new(None));
static ref LAST_SOFT_KEYBOARD_EVENT: Arc<RwLock<Option<KeyboardEvent>>> =
Arc::new(RwLock::new(None));
}
#[allow(dead_code)]
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
#[no_mangle]
#[unsafe(no_mangle)]
/// Callback from Java code with last entered character from soft keyboard.
pub extern "C" fn Java_mw_gri_android_MainActivity_onTextInput(
_env: jni::JNIEnv,
_class: jni::objects::JObject,
char: jni::sys::jstring
_env: jni::JNIEnv,
_class: jni::objects::JObject,
char: jni::sys::jstring,
) {
use jni::objects::JString;
use jni::objects::JString;
unsafe {
let j_obj = JString::from_raw(char);
let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap();
match j_str.to_str() {
Ok(str) => {
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
*w_input = Some(KeyboardEvent::TEXT(str.to_string()));
}
Err(_) => {}
}
}
unsafe {
let j_obj = JString::from_raw(char);
let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap();
match j_str.to_str() {
Ok(str) => {
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
*w_input = Some(KeyboardEvent::TEXT(str.to_string()));
}
Err(_) => {}
}
}
}
#[allow(dead_code)]
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
#[no_mangle]
#[unsafe(no_mangle)]
/// Callback from Java code when Clear key was pressed at soft keyboard.
pub extern "C" fn Java_mw_gri_android_MainActivity_onClearInput(
_env: jni::JNIEnv,
_class: jni::objects::JObject,
_env: jni::JNIEnv,
_class: jni::objects::JObject,
) {
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
*w_input = Some(KeyboardEvent::CLEAR);
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
*w_input = Some(KeyboardEvent::CLEAR);
}
#[allow(dead_code)]
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
#[no_mangle]
#[unsafe(no_mangle)]
/// Callback from Java code when Enter key was pressed at soft keyboard.
pub extern "C" fn Java_mw_gri_android_MainActivity_onEnterInput(
_env: jni::JNIEnv,
_class: jni::objects::JObject,
_env: jni::JNIEnv,
_class: jni::objects::JObject,
) {
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
*w_input = Some(KeyboardEvent::ENTER);
}
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
*w_input = Some(KeyboardEvent::ENTER);
}
+489 -449
View File
@@ -12,498 +12,538 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::string::ToString;
use egui::{Align, Align2, Button, Color32, CursorIcon, Layout, Margin, Rect, Response, RichText, Sense, Shadow, Vec2, Widget};
use egui::{
Align, Align2, Button, Color32, CursorIcon, Layout, Margin, Rect, Response, RichText, Sense,
Shadow, Vec2, Widget,
};
use lazy_static::lazy_static;
use parking_lot::RwLock;
use std::sync::atomic::Ordering;
use std::string::ToString;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use crate::AppConfig;
use crate::gui::Colors;
use crate::gui::icons::{ARROW_FAT_UP, BACKSPACE, GLOBE_SIMPLE, KEY_RETURN};
use crate::gui::views::{KeyboardEvent, KeyboardLayout, KeyboardState, View};
use crate::gui::Colors;
use crate::AppConfig;
lazy_static! {
/// Keyboard window state.
static ref WINDOW_STATE: Arc<RwLock<KeyboardState >> = Arc::new(
RwLock::new(KeyboardState::default())
);
/// Keyboard window state.
static ref WINDOW_STATE: Arc<RwLock<KeyboardState >> = Arc::new(
RwLock::new(KeyboardState::default())
);
}
/// Software keyboard content.
pub struct KeyboardContent {
/// Keyboard content state.
state: KeyboardState,
/// Keyboard content state.
state: KeyboardState,
}
impl Default for KeyboardContent {
fn default() -> Self {
Self {
state: KeyboardState::default(),
}
}
fn default() -> Self {
Self {
state: KeyboardState::default(),
}
}
}
impl KeyboardContent {
/// Maximum keyboard content width.
const MAX_WIDTH: f32 = 600.0;
/// Maximum numbers layout width.
const MAX_WIDTH_NUMBERS: f32 = 400.0;
/// Maximum keyboard content width.
const MAX_WIDTH: f32 = 600.0;
/// Maximum numbers layout width.
const MAX_WIDTH_NUMBERS: f32 = 400.0;
/// Keyboard window id.
pub const WINDOW_ID: &'static str = "soft_keyboard_window";
/// Keyboard window id.
pub const WINDOW_ID: &'static str = "soft_keyboard_window";
/// Draw keyboard content as separate [`Window`].
pub fn window_ui(&mut self, numeric: bool, ctx: &egui::Context) {
let width = ctx.content_rect().width();
let layer_id = egui::Window::new(Self::WINDOW_ID)
.title_bar(false)
.resizable(false)
.collapsible(false)
.min_width(width)
.default_width(width)
.anchor(Align2::CENTER_BOTTOM, Vec2::new(0.0, 0.0))
.frame(egui::Frame {
shadow: Shadow {
offset: Default::default(),
blur: 30.0 as u8,
spread: 3.0 as u8,
color: Color32::from_black_alpha(32),
},
inner_margin: Margin {
left: View::get_left_inset() as i8,
right: View::get_right_inset() as i8,
top: 1.0 as i8,
bottom: View::get_bottom_inset() as i8,
},
fill: Colors::fill(),
..Default::default()
})
.show(ctx, |ui| {
ui.set_min_width(width);
// Setup state.
{
let r_state = WINDOW_STATE.read();
self.state = (*r_state).clone();
}
// Calculate content width.
let side_insets = View::get_left_inset() + View::get_right_inset();
let available_width = width - side_insets;
let w = f32::min(available_width, if numeric {
Self::MAX_WIDTH_NUMBERS
} else {
Self::MAX_WIDTH
});
// Draw content.
View::max_width_ui(ui, w, |ui| {
self.ui(numeric, ui);
});
// Save state.
let mut w_state = WINDOW_STATE.write();
*w_state = self.state.clone();
}).unwrap().response.layer_id;
/// Draw keyboard content as separate [`Window`].
pub fn window_ui(&mut self, numeric: bool, ctx: &egui::Context) {
let width = ctx.content_rect().width();
let layer_id = egui::Window::new(Self::WINDOW_ID)
.title_bar(false)
.resizable(false)
.collapsible(false)
.min_width(width)
.default_width(width)
.anchor(Align2::CENTER_BOTTOM, Vec2::new(0.0, 0.0))
.frame(egui::Frame {
shadow: Shadow {
offset: Default::default(),
blur: 30.0 as u8,
spread: 3.0 as u8,
color: Color32::from_black_alpha(32),
},
inner_margin: Margin {
left: View::get_left_inset() as i8,
right: View::get_right_inset() as i8,
top: 1.0 as i8,
bottom: View::get_bottom_inset() as i8,
},
fill: Colors::fill(),
..Default::default()
})
.show(ctx, |ui| {
ui.set_min_width(width);
// Setup state.
{
let r_state = WINDOW_STATE.read();
self.state = (*r_state).clone();
}
// Calculate content width.
let side_insets = View::get_left_inset() + View::get_right_inset();
let available_width = width - side_insets;
let w = f32::min(
available_width,
if numeric {
Self::MAX_WIDTH_NUMBERS
} else {
Self::MAX_WIDTH
},
);
// Draw content.
View::max_width_ui(ui, w, |ui| {
self.ui(numeric, ui);
});
// Save state.
let mut w_state = WINDOW_STATE.write();
*w_state = self.state.clone();
})
.unwrap()
.response
.layer_id;
// Always show keyboard above others windows.
ctx.move_to_top(layer_id);
}
// Always show keyboard above others windows.
ctx.move_to_top(layer_id);
}
/// Draw keyboard content.
pub fn ui(&mut self, numeric: bool, ui: &mut egui::Ui) {
// Setup layout.
if numeric {
self.state.layout = Arc::new(KeyboardLayout::NUMBERS);
} else if *self.state.layout == KeyboardLayout::NUMBERS {
self.state.layout = Arc::new(KeyboardLayout::TEXT);
}
/// Draw keyboard content.
pub fn ui(&mut self, numeric: bool, ui: &mut egui::Ui) {
// Setup layout.
if numeric {
self.state.layout = Arc::new(KeyboardLayout::NUMBERS);
} else if *self.state.layout == KeyboardLayout::NUMBERS {
self.state.layout = Arc::new(KeyboardLayout::TEXT);
}
// Setup spacing between buttons.
ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0);
// Setup vertical padding inside buttons.
ui.style_mut().spacing.button_padding = egui::vec2(0.0, if numeric {
12.0
} else {
10.0
});
// Setup spacing between buttons.
ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0);
// Setup vertical padding inside buttons.
ui.style_mut().spacing.button_padding = egui::vec2(0.0, if numeric { 12.0 } else { 10.0 });
// Draw input buttons.
let button_rect = match *self.state.layout {
KeyboardLayout::TEXT => self.text_ui(ui),
KeyboardLayout::SYMBOLS => self.symbols_ui(ui),
KeyboardLayout::NUMBERS => self.numbers_ui(ui),
};
// Draw input buttons.
let button_rect = match *self.state.layout {
KeyboardLayout::TEXT => self.text_ui(ui),
KeyboardLayout::SYMBOLS => self.symbols_ui(ui),
KeyboardLayout::NUMBERS => self.numbers_ui(ui),
};
// Draw bottom keyboard buttons.
let bottom_size = {
let mut r = button_rect.clone();
r.set_width(ui.available_width());
r.size()
};
let button_width = ui.available_width() / match *self.state.layout {
KeyboardLayout::TEXT => 11.0,
KeyboardLayout::SYMBOLS => 10.0,
KeyboardLayout::NUMBERS => 4.0,
};
ui.allocate_ui_with_layout(bottom_size, Layout::right_to_left(Align::Center), |ui| {
match *self.state.layout {
KeyboardLayout::TEXT => {
// Enter key input.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 2.0);
self.custom_button_ui(KEY_RETURN.to_string(),
Colors::white_or_black(false),
Some(Colors::green()),
ui,
|_, c| {
c.state.last_event =
Arc::new(Some(KeyboardEvent::ENTER));
});
});
// Custom input key.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.input_button_ui("m3", true, ui);
});
// Space key input.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 5.0);
self.custom_button_ui(" ".to_string(),
Colors::inactive_text(),
None,
ui,
|l, c| {
c.state.last_event =
Arc::new(Some(KeyboardEvent::TEXT(l)));
});
});
// Switch to english and back.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.custom_button_ui(GLOBE_SIMPLE.to_string(),
Colors::text_button(),
Some(Colors::fill_lite()),
ui,
|_, _| {
AppConfig::toggle_english_keyboard()
});
});
// Switch to symbols layout.
self.custom_button_ui("!@ツ".to_string(),
Colors::text_button(),
Some(Colors::fill_lite()),
ui,
|_, c| {
c.state.layout = Arc::new(KeyboardLayout::SYMBOLS);
});
}
KeyboardLayout::SYMBOLS => {
// Enter key input.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 2.0);
self.custom_button_ui(KEY_RETURN.to_string(),
Colors::white_or_black(false),
Some(Colors::green()),
ui,
|_, c| {
c.state.last_event =
Arc::new(Some(KeyboardEvent::ENTER));
});
});
// Custom input key.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.input_button_ui("", false, ui);
});
// Space key input.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 4.0);
self.custom_button_ui(" ".to_string(),
Colors::inactive_text(),
None,
ui,
|l, c| {
c.state.last_event =
Arc::new(Some(KeyboardEvent::TEXT(l)));
});
});
// Switch to text layout.
let label = {
let q = t!("keyboard.q", locale = Self::input_locale().as_str());
let w = t!("keyboard.w", locale = Self::input_locale().as_str());
let e = t!("keyboard.e", locale = Self::input_locale().as_str());
format!("{}{}{}", q, w, e).to_uppercase()
};
self.custom_button_ui(label,
Colors::text_button(),
Some(Colors::fill_lite()),
ui,
|_, c| {
c.state.layout = Arc::new(KeyboardLayout::TEXT);
});
}
KeyboardLayout::NUMBERS => {
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 2.0);
self.custom_button_ui(KEY_RETURN.to_string(),
Colors::white_or_black(false),
Some(Colors::green()),
ui,
|_, c| {
c.state.last_event =
Arc::new(Some(KeyboardEvent::ENTER));
});
});
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.input_button_ui("0", true, ui);
});
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.input_button_ui(".", false, ui);
});
}
}
});
}
// Draw bottom keyboard buttons.
let bottom_size = {
let mut r = button_rect.clone();
r.set_width(ui.available_width());
r.size()
};
let button_width = ui.available_width()
/ match *self.state.layout {
KeyboardLayout::TEXT => 11.0,
KeyboardLayout::SYMBOLS => 10.0,
KeyboardLayout::NUMBERS => 4.0,
};
ui.allocate_ui_with_layout(bottom_size, Layout::right_to_left(Align::Center), |ui| {
match *self.state.layout {
KeyboardLayout::TEXT => {
// Enter key input.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 2.0);
self.custom_button_ui(
KEY_RETURN.to_string(),
Colors::white_or_black(false),
Some(Colors::green()),
ui,
|_, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::ENTER));
},
);
});
// Custom input key.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.input_button_ui("m3", true, ui);
});
// Space key input.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 5.0);
self.custom_button_ui(
" ".to_string(),
Colors::inactive_text(),
None,
ui,
|l, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::TEXT(l)));
},
);
});
// Switch to english and back.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.custom_button_ui(
GLOBE_SIMPLE.to_string(),
Colors::text_button(),
Some(Colors::fill_lite()),
ui,
|_, _| AppConfig::toggle_english_keyboard(),
);
});
// Switch to symbols layout.
self.custom_button_ui(
"!@ツ".to_string(),
Colors::text_button(),
Some(Colors::fill_lite()),
ui,
|_, c| {
c.state.layout = Arc::new(KeyboardLayout::SYMBOLS);
},
);
}
KeyboardLayout::SYMBOLS => {
// Enter key input.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 2.0);
self.custom_button_ui(
KEY_RETURN.to_string(),
Colors::white_or_black(false),
Some(Colors::green()),
ui,
|_, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::ENTER));
},
);
});
// Custom input key.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.input_button_ui("", false, ui);
});
// Space key input.
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 4.0);
self.custom_button_ui(
" ".to_string(),
Colors::inactive_text(),
None,
ui,
|l, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::TEXT(l)));
},
);
});
// Switch to text layout.
let label = {
let q = t!("keyboard.q", locale = Self::input_locale().as_str());
let w = t!("keyboard.w", locale = Self::input_locale().as_str());
let e = t!("keyboard.e", locale = Self::input_locale().as_str());
format!("{}{}{}", q, w, e).to_uppercase()
};
self.custom_button_ui(
label,
Colors::text_button(),
Some(Colors::fill_lite()),
ui,
|_, c| {
c.state.layout = Arc::new(KeyboardLayout::TEXT);
},
);
}
KeyboardLayout::NUMBERS => {
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width * 2.0);
self.custom_button_ui(
KEY_RETURN.to_string(),
Colors::white_or_black(false),
Some(Colors::green()),
ui,
|_, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::ENTER));
},
);
});
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.input_button_ui("0", true, ui);
});
ui.horizontal_centered(|ui| {
ui.set_max_width(button_width);
self.input_button_ui(".", false, ui);
});
}
}
});
}
/// Draw numbers content returning button [`Rect`].
fn numbers_ui(&mut self, ui: &mut egui::Ui) -> Rect {
let mut button_rect = ui.available_rect_before_wrap();
let tl_0: Vec<&str> = vec!["1", "2", "3", "+"];
ui.columns(tl_0.len(), |columns| {
for (index, s) in tl_0.iter().enumerate() {
let last = index == tl_0.len() - 1;
button_rect = self.input_button_ui(s, !last, &mut columns[index]);
}
});
/// Draw numbers content returning button [`Rect`].
fn numbers_ui(&mut self, ui: &mut egui::Ui) -> Rect {
let mut button_rect = ui.available_rect_before_wrap();
let tl_0: Vec<&str> = vec!["1", "2", "3", "+"];
ui.columns(tl_0.len(), |columns| {
for (index, s) in tl_0.iter().enumerate() {
let last = index == tl_0.len() - 1;
button_rect = self.input_button_ui(s, !last, &mut columns[index]);
}
});
let tl_1: Vec<&str> = vec!["4", "5", "6", ","];
ui.columns(tl_1.len(), |columns| {
for (index, s) in tl_1.iter().enumerate() {
let last = index == tl_1.len() - 1;
self.input_button_ui(s, !last, &mut columns[index]);
}
});
let tl_1: Vec<&str> = vec!["4", "5", "6", ","];
ui.columns(tl_1.len(), |columns| {
for (index, s) in tl_1.iter().enumerate() {
let last = index == tl_1.len() - 1;
self.input_button_ui(s, !last, &mut columns[index]);
}
});
let tl_2: Vec<&str> = vec!["7", "8", "9", BACKSPACE];
ui.columns(tl_2.len(), |columns| {
for (index, s) in tl_2.iter().enumerate() {
if index == tl_2.len() - 1 {
self.custom_button_ui(BACKSPACE.to_string(),
Colors::red(),
Some(Colors::fill_lite()),
&mut columns[index],
|_, c| {
c.state.last_event =
Arc::new(Some(KeyboardEvent::CLEAR));
});
} else {
self.input_button_ui(s, true, &mut columns[index]);
}
}
});
let tl_2: Vec<&str> = vec!["7", "8", "9", BACKSPACE];
ui.columns(tl_2.len(), |columns| {
for (index, s) in tl_2.iter().enumerate() {
if index == tl_2.len() - 1 {
self.custom_button_ui(
BACKSPACE.to_string(),
Colors::red(),
Some(Colors::fill_lite()),
&mut columns[index],
|_, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::CLEAR));
},
);
} else {
self.input_button_ui(s, true, &mut columns[index]);
}
}
});
button_rect
}
button_rect
}
/// Draw text content returning button [`Rect`].
fn text_ui(&mut self, ui: &mut egui::Ui) -> Rect {
let mut button_rect = ui.available_rect_before_wrap();
let tl_0: Vec<&str> = vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "01"];
ui.columns(tl_0.len(), |columns| {
for (index, s) in tl_0.iter().enumerate() {
button_rect = self.input_button_ui(s, true, &mut columns[index]);
}
});
/// Draw text content returning button [`Rect`].
fn text_ui(&mut self, ui: &mut egui::Ui) -> Rect {
let mut button_rect = ui.available_rect_before_wrap();
let tl_0: Vec<&str> = vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "01"];
ui.columns(tl_0.len(), |columns| {
for (index, s) in tl_0.iter().enumerate() {
button_rect = self.input_button_ui(s, true, &mut columns[index]);
}
});
let tl_1: Vec<&str> = vec!["q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "p1"];
ui.columns(tl_1.len(), |columns| {
for (index, s) in tl_1.iter().enumerate() {
self.input_button_ui(s, true, &mut columns[index]);
}
});
let tl_1: Vec<&str> = vec!["q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "p1"];
ui.columns(tl_1.len(), |columns| {
for (index, s) in tl_1.iter().enumerate() {
self.input_button_ui(s, true, &mut columns[index]);
}
});
let tl_2: Vec<&str> = vec!["a", "s", "d", "f", "g", "h", "j", "k", "l", "l1", "l2"];
ui.columns(tl_2.len(), |columns| {
for (index, s) in tl_2.iter().enumerate() {
self.input_button_ui(s, true, &mut columns[index]);
}
});
let tl_2: Vec<&str> = vec!["a", "s", "d", "f", "g", "h", "j", "k", "l", "l1", "l2"];
ui.columns(tl_2.len(), |columns| {
for (index, s) in tl_2.iter().enumerate() {
self.input_button_ui(s, true, &mut columns[index]);
}
});
let tl_3: Vec<&str> =
vec![ARROW_FAT_UP, "z", "x", "c", "v", "b", "n", "m", "m1", "m2", BACKSPACE];
ui.columns(tl_3.len(), |columns| {
for (index, s) in tl_3.iter().enumerate() {
if index == 0 {
let shift = self.state.shift.load(Ordering::Relaxed);
let color = if shift {
Colors::yellow_dark()
} else {
Colors::inactive_text()
};
self.custom_button_ui(ARROW_FAT_UP.to_string(),
color,
Some(Colors::fill_lite()),
&mut columns[index],
|_, c| {
c.state.shift.store(!shift, Ordering::Relaxed);
});
} else if index == tl_3.len() - 1 {
self.custom_button_ui(BACKSPACE.to_string(),
Colors::red(),
Some(Colors::fill_lite()),
&mut columns[index],
|_, c| {
c.state.last_event =
Arc::new(Some(KeyboardEvent::CLEAR));
});
} else {
self.input_button_ui(s, true, &mut columns[index]);
}
}
});
let tl_3: Vec<&str> = vec![
ARROW_FAT_UP,
"z",
"x",
"c",
"v",
"b",
"n",
"m",
"m1",
"m2",
BACKSPACE,
];
ui.columns(tl_3.len(), |columns| {
for (index, s) in tl_3.iter().enumerate() {
if index == 0 {
let shift = self.state.shift.load(Ordering::Relaxed);
let color = if shift {
Colors::yellow_dark()
} else {
Colors::inactive_text()
};
self.custom_button_ui(
ARROW_FAT_UP.to_string(),
color,
Some(Colors::fill_lite()),
&mut columns[index],
|_, c| {
c.state.shift.store(!shift, Ordering::Relaxed);
},
);
} else if index == tl_3.len() - 1 {
self.custom_button_ui(
BACKSPACE.to_string(),
Colors::red(),
Some(Colors::fill_lite()),
&mut columns[index],
|_, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::CLEAR));
},
);
} else {
self.input_button_ui(s, true, &mut columns[index]);
}
}
});
button_rect
}
button_rect
}
/// Draw symbols content returning button [`Rect`].
fn symbols_ui(&mut self, ui: &mut egui::Ui) -> Rect {
let mut button_rect = ui.available_rect_before_wrap();
let tl_0: Vec<&str> = vec!["[", "]", "{", "}", "#", "%", "^", "*", "+", "="];
ui.columns(tl_0.len(), |columns| {
for (index, s) in tl_0.iter().enumerate() {
button_rect = self.input_button_ui(s, false, &mut columns[index]);
}
});
/// Draw symbols content returning button [`Rect`].
fn symbols_ui(&mut self, ui: &mut egui::Ui) -> Rect {
let mut button_rect = ui.available_rect_before_wrap();
let tl_0: Vec<&str> = vec!["[", "]", "{", "}", "#", "%", "^", "*", "+", "="];
ui.columns(tl_0.len(), |columns| {
for (index, s) in tl_0.iter().enumerate() {
button_rect = self.input_button_ui(s, false, &mut columns[index]);
}
});
let tl_1: Vec<&str> = vec!["_", "\\", "|", "~", "<", ">", "", "", "π", ""];
ui.columns(tl_1.len(), |columns| {
for (index, s) in tl_1.iter().enumerate() {
self.input_button_ui(s, false, &mut columns[index]);
}
});
let tl_1: Vec<&str> = vec!["_", "\\", "|", "~", "<", ">", "", "", "π", ""];
ui.columns(tl_1.len(), |columns| {
for (index, s) in tl_1.iter().enumerate() {
self.input_button_ui(s, false, &mut columns[index]);
}
});
let tl_2: Vec<&str> = vec!["-", "/", ":", ";", "(", ")", "`", "&", "@", "\""];
ui.columns(tl_2.len(), |columns| {
for (index, s) in tl_2.iter().enumerate() {
self.input_button_ui(s, false, &mut columns[index]);
}
});
let tl_2: Vec<&str> = vec!["-", "/", ":", ";", "(", ")", "`", "&", "@", "\""];
ui.columns(tl_2.len(), |columns| {
for (index, s) in tl_2.iter().enumerate() {
self.input_button_ui(s, false, &mut columns[index]);
}
});
let tl_3: Vec<&str> = vec![".", ",", "?", "!", "", "£", "¥", "$", "¢", BACKSPACE];
ui.columns(tl_3.len(), |columns| {
for (index, s) in tl_3.iter().enumerate() {
if index == tl_3.len() - 1 {
self.custom_button_ui(BACKSPACE.to_string(),
Colors::red(),
Some(Colors::fill_lite()),
&mut columns[index],
|_, c| {
c.state.last_event =
Arc::new(Some(KeyboardEvent::CLEAR));
});
} else {
self.input_button_ui(s, false, &mut columns[index]);
}
}
});
let tl_3: Vec<&str> = vec![".", ",", "?", "!", "", "£", "¥", "$", "¢", BACKSPACE];
ui.columns(tl_3.len(), |columns| {
for (index, s) in tl_3.iter().enumerate() {
if index == tl_3.len() - 1 {
self.custom_button_ui(
BACKSPACE.to_string(),
Colors::red(),
Some(Colors::fill_lite()),
&mut columns[index],
|_, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::CLEAR));
},
);
} else {
self.input_button_ui(s, false, &mut columns[index]);
}
}
});
button_rect
}
button_rect
}
/// Draw custom keyboard button.
fn custom_button_ui(&mut self,
s: String,
color: Color32,
bg: Option<Color32>,
ui: &mut egui::Ui,
cb: impl FnOnce(String, &mut KeyboardContent)) -> Response {
ui.vertical_centered_justified(|ui| {
// Disable expansion on click/hover.
ui.style_mut().visuals.widgets.hovered.expansion = 0.0;
ui.style_mut().visuals.widgets.active.expansion = 0.0;
// Setup fill colors.
ui.visuals_mut().widgets.inactive.weak_bg_fill = Colors::white_or_black(false);
ui.visuals_mut().widgets.hovered.weak_bg_fill = Colors::fill_lite();
ui.visuals_mut().widgets.active.weak_bg_fill = Colors::fill();
// Setup stroke colors.
ui.visuals_mut().widgets.inactive.bg_stroke = View::item_stroke();
ui.visuals_mut().widgets.hovered.bg_stroke = View::item_stroke();
ui.visuals_mut().widgets.active.bg_stroke = View::hover_stroke();
/// Draw custom keyboard button.
fn custom_button_ui(
&mut self,
s: String,
color: Color32,
bg: Option<Color32>,
ui: &mut egui::Ui,
cb: impl FnOnce(String, &mut KeyboardContent),
) -> Response {
ui.vertical_centered_justified(|ui| {
// Disable expansion on click/hover.
ui.style_mut().visuals.widgets.hovered.expansion = 0.0;
ui.style_mut().visuals.widgets.active.expansion = 0.0;
// Setup fill colors.
ui.visuals_mut().widgets.inactive.weak_bg_fill = Colors::white_or_black(false);
ui.visuals_mut().widgets.hovered.weak_bg_fill = Colors::fill_lite();
ui.visuals_mut().widgets.active.weak_bg_fill = Colors::fill();
// Setup stroke colors.
ui.visuals_mut().widgets.inactive.bg_stroke = View::item_stroke();
ui.visuals_mut().widgets.hovered.bg_stroke = View::item_stroke();
ui.visuals_mut().widgets.active.bg_stroke = View::hover_stroke();
let shift = self.state.shift.load(Ordering::Relaxed);
let label = if shift {
s.to_uppercase()
} else {
s.to_string()
};
let mut button = Button::new(RichText::new(label.clone()).size(18.0).color(color))
.corner_radius(egui::CornerRadius::ZERO);
if let Some(bg) = bg {
button = button.fill(bg);
}
// Setup long press/touch.
let long_press = s == BACKSPACE;
if long_press {
button = button.sense(Sense::click_and_drag());
}
// Draw button.
let resp = button.ui(ui).on_hover_cursor(CursorIcon::PointingHand);
if resp.clicked() || resp.long_touched() || resp.dragged() {
cb(label, self);
}
}).response
}
let shift = self.state.shift.load(Ordering::Relaxed);
let label = if shift {
s.to_uppercase()
} else {
s.to_string()
};
let mut button = Button::new(RichText::new(label.clone()).size(18.0).color(color))
.corner_radius(egui::CornerRadius::ZERO);
if let Some(bg) = bg {
button = button.fill(bg);
}
// Setup long press/touch.
let long_press = s == BACKSPACE;
if long_press {
button = button.sense(Sense::click_and_drag());
}
// Draw button.
let resp = button.ui(ui).on_hover_cursor(CursorIcon::PointingHand);
if resp.clicked() || resp.long_touched() || resp.dragged() {
cb(label, self);
}
})
.response
}
/// Draw input button.
fn input_button_ui(&mut self, s: &str, translate: bool, ui: &mut egui::Ui) -> Rect {
let value = if translate {
t!(format!("keyboard.{}", s), locale = Self::input_locale().as_str()).into()
} else {
s.to_string()
};
let rect = self.custom_button_ui(value, Colors::text_button(), None, ui, |l, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::TEXT(l)));
c.state.shift.store(false, Ordering::Relaxed);
}).rect;
rect
}
/// Draw input button.
fn input_button_ui(&mut self, s: &str, translate: bool, ui: &mut egui::Ui) -> Rect {
let value = if translate {
t!(
format!("keyboard.{}", s),
locale = Self::input_locale().as_str()
)
.into()
} else {
s.to_string()
};
let rect = self
.custom_button_ui(value, Colors::text_button(), None, ui, |l, c| {
c.state.last_event = Arc::new(Some(KeyboardEvent::TEXT(l)));
c.state.shift.store(false, Ordering::Relaxed);
})
.rect;
rect
}
/// Get input locale.
fn input_locale() -> String {
let english = AppConfig::english_keyboard();
if english {
"en".to_string()
} else {
AppConfig::locale().unwrap_or("en".to_string())
}
}
/// Get input locale.
fn input_locale() -> String {
let english = AppConfig::english_keyboard();
if english {
"en".to_string()
} else {
AppConfig::locale().unwrap_or("en".to_string())
}
}
/// Check last keyboard input event.
pub fn consume_event() -> Option<KeyboardEvent> {
let empty = {
let r_state = WINDOW_STATE.read();
r_state.last_event.is_none()
};
if !empty {
let mut w_state = WINDOW_STATE.write();
let event = w_state.last_event.as_ref().clone().unwrap();
w_state.last_event = Arc::new(None);
return Some(event);
}
None
}
/// Check last keyboard input event.
pub fn consume_event() -> Option<KeyboardEvent> {
let empty = {
let r_state = WINDOW_STATE.read();
r_state.last_event.is_none()
};
if !empty {
let mut w_state = WINDOW_STATE.write();
let event = w_state.last_event.as_ref().clone().unwrap();
w_state.last_event = Arc::new(None);
return Some(event);
}
None
}
/// Emulate stop of Shift key press.
pub fn unshift() {
let r_state = WINDOW_STATE.read();
r_state.shift.store(false, Ordering::Relaxed);
}
/// Emulate stop of Shift key press.
pub fn unshift() {
let r_state = WINDOW_STATE.read();
r_state.shift.store(false, Ordering::Relaxed);
}
/// Reset keyboard window state.
pub fn reset_window_state() {
let mut w_state = WINDOW_STATE.write();
w_state.layout = Arc::new(KeyboardLayout::TEXT);
// *w_state = KeyboardState::default();
}
}
/// Reset keyboard window state.
pub fn reset_window_state() {
let mut w_state = WINDOW_STATE.write();
w_state.layout = Arc::new(KeyboardLayout::TEXT);
// *w_state = KeyboardState::default();
}
}

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