373 Commits

Author SHA1 Message Date
ardocrat 84a47ee59a i18n: keyboard for zh-TC 2026-07-09 00:27:50 +03:00
ardocrat 8165379cf2 i18n: added sender address for zh-TC 2026-07-09 00:02:22 +03:00
ardocrat a5a491499f Merge branch 'refs/heads/master' into master-jasper
# Conflicts:
#	locales/zh-CN.yml
2026-07-08 23:59:20 +03:00
ardocrat f891c8f516 tor: fix send timeout 2026-07-08 23:02:35 +03:00
jasperli2026 38352bc75c Create zh-TC.yml 2026-06-24 09:24:09 +08:00
jasperli2026 f80ca03e5d Delete locales/zh-TC.yml 2026-06-24 09:23:31 +08:00
jasperli2026 2bc3f191a7 Create zh-TC.yaml 2026-06-24 09:20:22 +08:00
jasperli2026 ee56061d68 Update zh-CN.yml 2026-06-24 09:07:42 +08:00
ardocrat ee8841590a ci: recursive submodules 2026-06-23 15:42:43 +03:00
ardocrat 20db758bc2 wallet: update to last version, removing separate node module, added ability to finalize tx over tor 2026-06-23 15:35:17 +03:00
jasperli2026 c03293aba2 Update Chinese translations in zh-CN.yml 2026-06-23 17:37:40 +08:00
ardocrat 3981ebe3ed fix: repost pay tx over tor, do not try to send if tor is starting 2026-06-22 20:05:51 +03:00
ardocrat 161faba9a0 Merge remote-tracking branch 'refs/remotes/jwinterm/feat/auto-tor-reply-on-invoice-scan' 2026-06-22 18:44:01 +03:00
jwinterm 53bc6d3e3e wallet: auto-reply over Tor when scanning an Invoice1 slatepack
Mirrors the existing Standard1 + Send-task flow for the invoice
direction. When a customer scans an Invoice1 slatepack QR with an
embedded sender address (now standard for slatepacks produced by
grin-wallet's issue_invoice_tx), the patched pay() forwards the
sender address through create_slatepack_message, and the
OpenMessage handler — if the wallet's Tor service is running or
starting — pushes the signed Invoice2 to the sender's foreign-api
finalize_tx over Tor. The merchant's wallet finalizes + posts on
their side, so no local finalize/post is needed (cf. the existing
send_tor closure for Standard1 which does need that).

Backward-compatible: if the slatepack has no embedded sender
address (older clients) or the Tor service isn't up, the existing
write-slatepack-to-disk-for-paste-back fallback runs unchanged.
No protocol change, no new dependencies, no new failure modes —
the response slatepack file is always written before the Tor send
is attempted, so a Tor failure mid-flight is recoverable.

Closes the mobile-UX gap that required the customer to manually
copy the response slatepack from the wallet and paste it back to
the merchant's web/storefront interface. With this patch and a
foreign-api listener on the merchant side, scanning a Grin invoice
QR is now a single tap: scan → confirm → done.

send_tor() gains a `finalize: bool` parameter that selects between
the existing receive_tx body (for Standard1 sends) and a new
finalize_tx body (for the invoice-flow case). The same Tor SOCKS
plumbing handles both.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reviewed-on: https://code.gri.mw/GUI/grim/pulls/28
2026-01-09 11:07:37 +00:00
ardocrat 03fbb0914e wallet: optimize proxy url parse to use localhost or dns record 2025-11-25 23:49:05 +03:00
ardocrat ed2dc880aa android: migrate back to gameactivity, update to API 36, fix back navigation at network panel 2025-11-21 01:34:51 +03:00
ardocrat 646a7c5e04 fix: tx ui on repost 2025-11-21 01:33:53 +03:00
ardocrat 11a5a73775 sync: increase header size 2025-11-11 00:38:00 +03:00
ardocrat 48ec553e0a build: android version naming 2025-11-11 00:36:49 +03:00
ardocrat a567243716 ci: fix version naming and override 2025-11-11 00:35:26 +03:00
ardocrat 4773fdb8d5 fix: render on linux wayland 2025-11-10 15:59:22 +03:00
ardocrat 7f65471ba1 ui: fix wallet tabs state 2025-11-06 14:01:40 +03:00
ardocrat fabd0a90df build: remove tag symbol from android version 2025-11-06 13:21:16 +03:00
ardocrat 6093d2bddb build: fix android version naming 2025-11-06 13:19:11 +03:00
ardocrat 42bcda621a ui: update egui, fix android back button 2025-11-05 18:11:07 +03:00
ardocrat 215b5d3f27 ci: jobs deps 2025-11-05 18:09:38 +03:00
ardocrat 97d8b86d39 ci: forgejo 2025-11-05 13:26:05 +03:00
ardocrat 8ba11daf31 build: fix lifetime warning 2025-11-03 13:48:34 +03:00
ardocrat fe2f79ecad tor: update arti to 0.36 2025-11-03 13:48:18 +03:00
ardocrat 606072ca3a build: working android release by default 2025-10-27 17:21:37 +03:00
ardocrat 2eef58e23a Update images links 2025-10-22 23:55:17 +03:00
ardocrat cf4f0789a3 build: update egui to last github version 2025-06-25 13:13:31 +03:00
ardocrat 1b78118f51 fix: action repeat tor tx, no resend for tor 2025-06-25 12:51:29 +03:00
ardocrat a89a9bcaed fix: message opening 2025-06-25 11:50:49 +03:00
ardocrat 8528c33be5 fix: message opening when slate with previous state exists 2025-06-25 11:45:26 +03:00
ardocrat d1502e26b1 wallet: cleanup broadcasting delay on repost 2025-06-25 11:32:26 +03:00
ardocrat 2f56defffa wallet: broadcasting delay, repeat tx action 2025-06-25 11:08:57 +03:00
ardocrat 01af084568 build: update grin and tor deps 2025-06-19 09:34:20 +03:00
ardocrat cd0e3485c5 txs: async tasks for wallet 2025-06-19 09:18:20 +03:00
ardocrat b540fcbf19 tor: do not start already starting service 2025-06-11 15:16:33 +03:00
ardocrat 7d29b2af6d tx: qr padding, info buttons positions 2025-06-10 22:25:10 +03:00
ardocrat ad030fe811 fix: tx finalizing status setup 2025-06-10 22:16:51 +03:00
ardocrat fae1364f10 wallet: tx response flag to show sharing controls 2025-06-10 22:03:49 +03:00
ardocrat 93297b5401 tx: do not show sharing content when can not finalize 2025-06-10 21:20:27 +03:00
ardocrat 511611f994 wallet: show only txs with slate id 2025-06-10 20:40:50 +03:00
ardocrat e9e2a0a8e7 ui: fix tx description 2025-06-10 20:25:23 +03:00
ardocrat 1222399926 tx: remove manual slatepack input, scan outputs after wallet db deletion 2025-06-10 20:09:24 +03:00
ardocrat 845c1dc0ea i18n: file 2025-06-10 19:34:35 +03:00
ardocrat 3a21e60e19 ui: do not copy form animated qr 2025-06-10 19:30:36 +03:00
ardocrat 9622429180 build: remove unused module 2025-06-10 19:04:56 +03:00
ardocrat d04b7a4e6a build: update version name 2025-06-09 12:51:07 +03:00
ardocrat 8b369b6049 ui: refactoring of wallet screen, fix colors 2025-06-09 12:34:07 +03:00
ardocrat b54a573f61 tor: proxy settings 2025-06-09 12:27:36 +03:00
ardocrat 184326bfde wallet: open slatepack 2025-06-09 12:23:01 +03:00
ardocrat b1f3c7d42b fix: mnemonic input 2025-06-06 14:29:29 +03:00
ardocrat 53a96e567d wallet: sort accounts to show current first 2025-06-04 15:33:34 +03:00
ardocrat 20daa7b465 network: fix external connections check 2025-06-03 16:11:08 +03:00
ardocrat 0fa2ef4283 qr: smaller text 2025-06-02 21:54:13 +03:00
ardocrat e067a0a900 qr: add max size support, ui copy button 2025-06-02 21:03:49 +03:00
ardocrat 31d8e2f012 eframe: glow renderer 2025-06-02 12:10:41 +03:00
ardocrat 84d385ef1a macos: glow renderer 2025-06-01 23:11:57 +03:00
ardocrat fabef9492e proxy: tls support 2025-06-01 00:05:48 +03:00
ardocrat 92f8386264 http: client, wallet to node communication with proxy 2025-05-31 23:34:51 +03:00
ardocrat 1ef62a806b fix: show word list on wallet creation 2025-05-31 22:34:12 +03:00
ardocrat f8da3d0754 fix: hyper client import 2025-05-31 17:44:37 +03:00
ardocrat 8165fab326 tor: update arti-client to 0.30.0 2025-05-31 17:08:39 +03:00
ardocrat 918c5b4355 build: imports 2025-05-31 15:47:03 +03:00
ardocrat f930cd4ade config: node db path 2025-05-31 15:45:36 +03:00
ardocrat 3f3940e752 ui: remove storage settings 2025-05-31 15:45:15 +03:00
ardocrat 4ef5dd839d platform: pick folder 2025-05-31 15:44:24 +03:00
ardocrat fd14700eae settings: network proxy 2025-05-31 14:12:31 +03:00
ardocrat e5548eb6f1 fix: current locale check at modal 2025-05-31 09:20:46 +03:00
ardocrat a364daf52e ui: network and storage settings modules, language selection modal 2025-05-31 09:11:07 +03:00
ardocrat 7089e6e1b2 ui: update app title 2025-05-31 09:09:01 +03:00
ardocrat 0621154902 ui: remove on_back callback from content container 2025-05-30 22:11:16 +03:00
ardocrat acfb5fec1a ui: wallet content container, accounts panel 2025-05-30 21:25:29 +03:00
ardocrat 1a3df4619e ui: accounts module 2025-05-30 16:13:27 +03:00
ardocrat 8994775be2 fix: keyboard focus 2025-05-30 15:06:47 +03:00
ardocrat 81365dbe6a ui: reset keyboard window state on opening and inputs focus change 2025-05-30 14:48:49 +03:00
ardocrat 7ae63b2b66 fix: modal window focus 2025-05-30 14:14:58 +03:00
ardocrat b8dd5911d4 ui: animate wallet list panels 2025-05-30 13:01:12 +03:00
ardocrat 3fc4ffa179 fix: wallet and mnemonic modals for container 2025-05-30 12:50:33 +03:00
ardocrat b84f6480e7 ui: content container 2025-05-30 12:33:13 +03:00
ardocrat 5dd8de7950 modal: move focus setup to the root content 2025-05-29 23:50:26 +03:00
ardocrat 78baaca4a3 fix: keyboard modal focus 2025-05-29 15:37:00 +03:00
ardocrat e597ac7e4b ui: ability to not show soft keyboard for input, move modal on top only at first draw 2025-05-29 13:32:33 +03:00
ardocrat 4d5cc93a38 ui: settings content at separate panel 2025-05-29 12:56:49 +03:00
ardocrat ed50132d5e keyboard: long press clear 2025-05-29 01:31:12 +03:00
ardocrat fbb084f636 wallet: do not scan outputs for new wallet 2025-05-29 00:38:45 +03:00
ardocrat d42ef102b2 keyboard: layouts for languages 2025-05-28 20:16:23 +03:00
ardocrat 9673c7d719 keyboard: show refactoring 2025-05-28 13:46:44 +03:00
ardocrat 9b4623c558 keyboard: state refactoring 2025-05-28 12:58:57 +03:00
ardocrat b7563e63c1 ui: esc key handling for keyboard without modal 2025-05-28 11:11:22 +03:00
ardocrat 4d4b5eb007 keyboard: optimize buttons 2025-05-28 10:23:17 +03:00
ardocrat 6c04eec026 modal: close refactoring 2025-05-27 22:14:43 +03:00
ardocrat 1ff2b27edc android: release build script 2025-05-27 22:04:55 +03:00
ardocrat 6bce9ec071 android: switch to nativeactivity, fix clicks 2025-05-27 21:01:00 +03:00
ardocrat 98619cc362 ui: update to egui 0.31 2025-05-27 16:10:29 +03:00
ardocrat 1987d0553c ui: numeric keyboard input 2025-05-27 14:28:23 +03:00
ardocrat 3f78095fe3 ui: keyboard language switch 2025-05-27 13:08:32 +03:00
ardocrat 245766e1b5 fix: text width inside input content 2025-05-26 22:37:03 +03:00
ardocrat 2591653f66 ui: input refactoring 2025-05-26 20:48:29 +03:00
ardocrat d11e90226b feat: software keyboard (without language switch) 2025-05-23 19:20:42 +03:00
ardocrat fb159c17a0 i18n: chinese 2025-04-27 21:22:08 +03:00
ardocrat f7eb6580cc tor: trim address on send 2025-04-27 19:51:06 +03:00
ardocrat 43720b34ba fix: external connection deletion 2025-04-23 15:10:48 +03:00
ardocrat f1f0f002ce fix: content redraw at connections 2025-04-23 13:09:31 +03:00
ardocrat 86afa21a60 node: do not remove lock file on cleanup 2025-04-23 12:38:23 +03:00
ardocrat 0169acba81 build: use zig linker for macos and linux for arm on x86 2025-04-02 22:30:59 +03:00
ardocrat 073d950d41 github: disable release build 2025-04-02 21:10:45 +03:00
ardocrat 4eaaebd739 release: v0.2.4 2025-04-02 20:48:58 +03:00
ardocrat a9e2106fda git: ignore cargo parse result file 2025-04-02 20:48:11 +03:00
ardocrat 8b427989c5 github: disable release build 2025-04-02 20:37:47 +03:00
ardocrat f16ce3c69b fix: transparent background on desktop 2025-04-02 20:37:23 +03:00
ardocrat a1b3330e5e async: use tokio for thread block calls 2025-04-02 19:15:20 +03:00
ardocrat 3da8f5420b build: update tor arti 0.29.0 2025-04-02 17:05:20 +03:00
ardocrat 109e896506 tor: clean error after start 2025-04-02 16:47:07 +03:00
ardocrat 8ad38f381e ui: change values on enter press at node settings modals 2025-04-02 15:49:07 +03:00
ardocrat 1e32315346 win: use system window frame 2025-04-02 15:22:15 +03:00
ardocrat ef8c645a6a win: allow downgrade install 2025-04-02 14:32:00 +03:00
ardocrat 15ecdf1e57 build: update guid for win installer 2025-04-02 13:31:04 +03:00
ardocrat 587b00c93a build: version for windows 2025-04-01 00:26:59 +03:00
ardocrat aba2bead27 build: update package info, other dependencies 2025-03-31 21:21:51 +03:00
ardocrat 85ce58f69c fix: parse result from scan on top panel 2025-03-31 20:46:23 +03:00
ardocrat bb7e00b0eb fix: initial color theme setup 2025-03-29 21:52:10 +03:00
ardocrat d60b35ebef Merge pull request 'macos: use nokhwa camera dependency' (#16) from macos_camera_fix into master
Reviewed-on: https://gri.mw/code/code/GUI/grim/pulls/16
2025-03-29 21:36:25 +03:00
ardocrat eb60c52224 macos: use nokhwa camera dependency 2025-03-29 21:18:53 +03:00
ardocrat 61828ea2db build: update tor lib 2025-03-15 20:41:30 +03:00
ardocrat 7e819e14d1 node: fix peers config saving 2025-03-15 20:35:10 +03:00
ardocrat 1d9b7d9698 wallet: do not lock whole balance on send 2025-01-14 17:55:50 +03:00
ardocrat 82c05588bc readme: update title 2025-01-13 21:59:22 +03:00
ardocrat 1cddd05bc0 readme: update img tag 2025-01-13 21:58:29 +03:00
ardocrat 8ad0d1c461 readme: update images 2025-01-13 21:56:48 +03:00
ardocrat a22a75913c img: add grin logo 2025-01-13 21:55:55 +03:00
ardocrat e797da0ed8 img: add cover 2025-01-13 21:26:00 +03:00
ardocrat 6936c14ed2 tor: remove macos tls fix 2025-01-13 21:06:34 +03:00
ardocrat c626ed5a48 tor: clear data on launch, update arti to 0.26.0 2025-01-13 19:40:09 +03:00
ardocrat d79d05ef5a android: debug build without keystore 2025-01-13 16:54:27 +03:00
ardocrat 094a5b8969 release: v0.2.3 2024-10-27 20:12:12 +03:00
ardocrat 12a75f8370 macos: future version update 2024-10-27 19:45:00 +03:00
ardocrat 1c14b9aa93 tx: fix confirmation status for new block, do not show Slatepack message after finalization 2024-10-27 19:02:17 +03:00
ardocrat 8ea388554a github: macos target 11.0 2024-10-27 18:07:22 +03:00
ardocrat 1531c201bb github: macos 12 2024-10-27 00:46:53 +03:00
ardocrat ed522c56ae github: macos zig linker 2024-10-27 00:40:58 +03:00
ardocrat 4b454ab2f3 github: macos last os 2024-10-27 00:29:27 +03:00
ardocrat f6fbf7226e fix: window size saving 2024-10-26 23:54:47 +03:00
ardocrat ebd09ab1c8 camera: update nokhwa, eye for macos, ability to switch camera when another camera not loaded 2024-10-26 23:25:55 +03:00
ardocrat 75cf7edc96 fix: modal padding and window border on desktop 2024-10-26 23:23:39 +03:00
ardocrat 5c8b9c40be build: provide version for android release 2024-10-26 02:17:28 +03:00
ardocrat dcaf9945c8 ui: wgpu renderer for macos, desktop content background fix, do not show left line for camera content at dual panel mode 2024-10-26 02:16:47 +03:00
ardocrat f9426287d5 macos: release on darwin without zig, info.plist camera usage description and version update 2024-10-25 20:03:57 +03:00
ardocrat 77281e3ab9 github: fix macos arm sdk 2024-10-23 00:37:41 +03:00
ardocrat 64439ad3d3 github: fix macos deployment target 2024-10-23 00:11:45 +03:00
ardocrat 9494c1292e github: macos coreutils 2024-10-22 23:47:19 +03:00
ardocrat accf123d49 github: macos build 2024-10-22 23:24:02 +03:00
ardocrat d77598c259 github: fix macos sdk env 2024-10-22 04:39:08 +03:00
ardocrat 4e6dff52fe github: macos install zig 2024-10-22 04:14:21 +03:00
ardocrat 92d0aac250 github: fix macos sdk unzip 2024-10-22 04:08:26 +03:00
ardocrat 5ef310558a release: v0.2.2 2024-10-22 03:51:01 +03:00
ardocrat 683821b667 build: fix version script 2024-10-22 03:50:48 +03:00
ardocrat da4cf71fac github: build macos on linux with SDK 10.15 2024-10-22 03:19:34 +03:00
ardocrat f81ceae940 txs: new block confirmation time 2024-10-22 02:11:25 +03:00
ardocrat fa6301a1db stratum: fix wallet name after selection, do not panic after stop 2024-10-22 00:12:13 +03:00
ardocrat 442fc425f7 ui: update to egui 0.29.1, wallet qr scan content, panels strokes and colors refactoring, check closeable modal at desktop title, fix app socket name 2024-10-21 12:03:09 +03:00
ardocrat ea61588ede build: check android lib result 2024-10-12 19:58:14 +03:00
ardocrat 7f67aa134a build: increment on android development 2024-10-12 15:33:23 +03:00
ardocrat d7d1c53c52 build: incremental release on desktop development 2024-10-12 15:26:01 +03:00
ardocrat 18f52f877a node: remove delay after server start 2024-10-12 15:24:15 +03:00
ardocrat c13195bd61 stratum: prevent crash at connections thread 2024-10-10 21:51:28 +03:00
ardocrat e40d5b6474 node: single function to get api secrets 2024-10-10 21:11:50 +03:00
ardocrat 92e5d38755 build: update grin 5.3.3, arti 0.23.0 (fork arti-hyper crate) and non-egui dependencies 2024-10-09 12:58:59 +03:00
ardocrat ec7e795ba9 build: camera features 2024-10-09 10:13:55 +03:00
ardocrat af220b2a09 camera: remove eye-rs to fix build for mac, horizontally flip image 2024-10-08 23:23:04 +03:00
ardocrat 846e30cb38 app: better panic handling, macos single app instance 2024-10-08 17:11:45 +03:00
ardocrat d371d4368b wallet: disable tor listener by default 2024-10-08 14:59:51 +03:00
ardocrat 85fc8101e4 ui: show tx modal on error if exists 2024-10-08 02:37:51 +03:00
ardocrat e2f58a8938 android: update gradle 2024-10-07 20:55:23 +03:00
ardocrat 7e6954afd9 fix: opened file data providing 2024-10-07 19:45:29 +03:00
ardocrat bed041a1c3 git: ignore android release artifacts 2024-09-21 00:33:46 +03:00
ardocrat f955f720d2 release: v0.2.1 2024-09-20 23:33:08 +03:00
ardocrat b627ac1ca6 fix: mnemonic import 2024-09-20 23:30:41 +03:00
ardocrat ac0b218376 fix: connection selection 2024-09-20 23:12:44 +03:00
ardocrat 04bf5a5349 github: coreutils for macos 2024-09-20 21:46:17 +03:00
ardocrat 9cce52a7d9 github: fix sha256sum 2024-09-20 20:38:05 +03:00
ardocrat 51e0d87d27 github: fix release 2024-09-20 15:17:41 +03:00
ardocrat d6f7e2e976 github: release sha256sum 2024-09-20 15:15:18 +03:00
ardocrat 0bbf395a62 build: android warning fix 2024-09-20 15:03:56 +03:00
ardocrat 609d7ceb7a build: remove panic message dependency 2024-09-20 14:45:40 +03:00
ardocrat b91605864d github: fix macos release 2024-09-20 14:42:37 +03:00
ardocrat 7857b708c9 release: v0.2.0 2024-09-20 14:17:03 +03:00
ardocrat a0f85538e9 ui: tx modal height 2024-09-20 14:09:53 +03:00
ardocrat c52da4f479 wallet: accounts balance calculating optimization, payment proof support on send, selection_strategy_is_use_all 2024-09-20 13:56:25 +03:00
ardocrat af597df7b1 i18n: move confirmation word 2024-09-20 13:49:31 +03:00
ardocrat 2adb29f4ee ui: external connection check and ui repaint fix, tab button callback argument 2024-09-20 13:42:45 +03:00
ardocrat 2b83944f34 ui: show node error status on connection item 2024-09-20 11:10:05 +03:00
ardocrat 71e80f6df7 ui: reset node config from ui on error 2024-09-20 10:58:52 +03:00
ardocrat 0ead11ec6c tx: receiver address 2024-09-20 02:39:06 +03:00
ardocrat 3e249c5314 android: share file type 2024-09-20 00:16:12 +03:00
ardocrat bacc87945c messages: qr scan modal 2024-09-20 00:09:08 +03:00
ardocrat 2cfd428c4c ui: do not clear qr state 2024-09-19 21:39:59 +03:00
ardocrat c155deedb5 wallet: qr scan modal, connections content and default list, wallet creation and list refactoring, tx height 2024-09-19 15:56:53 +03:00
Ardocrat 3bc8c407b4 Merge pull request #13 from ardocrat/slatepack_ext_file
Open .slatepack file with the app
2024-09-16 16:08:27 +00:00
ardocrat c3fae38d5c desktop: open camera check 2024-09-15 15:54:07 +03:00
ardocrat d6ec4213ab ui: ability to finalize tx only when wallet is loaded 2024-09-14 21:21:03 +03:00
ardocrat 150a0de1c4 android: always build with release-apk profile 2024-09-14 21:17:43 +03:00
ardocrat 7cedebc70e ui: qr scan and accounts modals module, parsing messages fix 2024-09-14 21:11:52 +03:00
ardocrat fe5aca6f0e build: remove debug from release profile 2024-09-14 16:08:40 +03:00
ardocrat 5d83710fed ui: dark colors fix 2024-09-14 16:02:20 +03:00
ardocrat 1431e307ee ui: separate wallet accounts modal 2024-09-14 15:21:08 +03:00
ardocrat 1934dc3377 desktop: args text 2024-09-14 15:04:11 +03:00
ardocrat 8af06d8860 build: android fix 2024-09-14 13:07:48 +03:00
ardocrat 9ea0da95b7 build: release sha256sum 2024-09-14 12:12:50 +03:00
ardocrat d39e2ec21e build: android signed release 2024-09-14 02:06:35 +03:00
ardocrat 68c9c9df04 build: local android release 2024-09-14 01:47:06 +03:00
ardocrat 6f7156ef17 github: android secrets 2024-09-13 22:31:28 +03:00
ardocrat 50638ff54e github: android keystore 2024-09-13 22:00:59 +03:00
ardocrat 8594279b98 android: java call result fixes 2024-09-13 21:08:14 +03:00
ardocrat 0205e01b3c build: macos fix 2024-09-13 19:51:33 +03:00
ardocrat 17545c1b7c macos: platform build 2024-09-13 18:57:09 +03:00
ardocrat bcf821c06a macos: initial file type association 2024-09-13 15:21:43 +03:00
ardocrat 34376d3490 build: fix macos 2024-09-13 14:56:04 +03:00
ardocrat 8ed2308340 macos: build, warn fix 2024-09-13 14:53:22 +03:00
ardocrat c73cd58eed platform: android file opening, better exit 2024-09-13 14:22:15 +03:00
ardocrat d78ec570b0 platform: passed data at lib, desktop user attention, check existing file on share at android 2024-09-12 21:27:37 +03:00
ardocrat dd45f7ce38 desktop: platform socket fix, file extension association for windows 2024-09-12 18:02:02 +03:00
ardocrat fb7312cb80 desktop: request window focus on data 2024-09-11 21:13:52 +03:00
ardocrat dbc28205e8 desktop: parse file content from argument on launch, single app instance, wallets selection and opening modals refactoring 2024-09-11 17:01:05 +03:00
ardocrat a3ed3bd234 build: linux release 2024-09-07 12:45:05 +03:00
ardocrat 21ecf200b8 wallet + ui: optimize sync after tx actions, remove tx repost, share message as file from tx modal, show tx info after tor sending and message creation or finalization, messages and transport modules refactoring, qr code text optimization, wallet dandelion setting, recovery phrase modal next step on enter 2024-09-07 00:11:17 +03:00
ardocrat c8bca08bdc txs: share message as file from modal, module refactoring 2024-08-15 23:09:42 +03:00
ardocrat 68bd2b81ec peers: fix config edit and load, default mainnet dnsseed 2024-08-13 02:31:38 +03:00
ardocrat 09cfb84b94 fix: ellipsized sync status text at connections 2024-08-12 18:30:10 +03:00
ardocrat 5c1ffb5636 build: push version 2024-08-10 12:15:40 +03:00
ardocrat 7f79cc0708 release: v0.1.3 2024-08-10 12:08:20 +03:00
ardocrat b0b4f9068a build: version release 2024-08-10 11:59:12 +03:00
ardocrat cb9e86750c mnemonic: words import and errors check refactoring 2024-08-10 02:35:42 +03:00
ardocrat 86fbf2e14f github: fix android build 2024-08-08 03:08:10 +03:00
ardocrat e0351cea84 fix: mnemonic words size on creation, wallet creation errors 2024-08-08 03:01:08 +03:00
164 changed files with 35777 additions and 24538 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Test build
on:
push:
tags-ignore:
- "*"
branches-ignore:
- master
- ci
jobs:
build:
runs-on: ubuntu
steps:
- uses: actions/checkout@v6
- name: Checkout submodules
run: |
sed -i 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
git submodule update --init --recursive --remote
- 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
@@ -0,0 +1,29 @@
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"
+474
View File
@@ -0,0 +1,474 @@
name: Release build
on:
push:
branches:
- master
- ci
tags-ignore:
- "*-dev*"
jobs:
version:
runs-on: debian-release
outputs:
v: ${{ steps.version.outputs.v }}
pre: ${{ steps.version.outputs.pre }}
exists: ${{ steps.check.outputs.exists }}
last_tag: ${{ steps.check_prev.outputs.last_tag }}
steps:
- uses: actions/checkout@v6
- name: Checkout submodules
run: |
sed -i 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
git submodule update --init --recursive --remote
- name: Get version
id: version
run: |
ver="$(grep -m 1 -Po 'version = "\K[^"]*' Cargo.toml)"
[[ $ver == *"-dev"* ]] && ver=${ver} || ver=${ver}-dev
[[ ${{ forgejo.ref_type }} == 'tag' ]] && app_ver=${{ forgejo.ref_name }} || app_ver=v${ver}
echo "v=${app_ver}" >> "$FORGEJO_OUTPUT"
echo $app_ver
[[ ${{ forgejo.ref_type }} == 'tag' ]] && pre='false' || pre='true'
echo "pre=${pre}" >> "$FORGEJO_OUTPUT"
echo "pre-release: ${pre}"
- name: Check existing release
if: ${{ forgejo.ref_type == 'tag' }}
id: check
run: |
git fetch --tags
dev_sha=$(git rev-parse refs/tags/${{ forgejo.ref_name }}-dev) || :
[[ "$(git show-ref)" == *"${dev_sha}"* ]] && exists='true' || exists='false'
echo "exists=${exists}" >> "$FORGEJO_OUTPUT"
echo ${exists}
mkdir release
- uses: actions/forgejo-release@v2.11.3
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
with:
direction: download
token: ${{ secrets.RELEASE_TOKEN }}
tag: "${{ forgejo.ref_name }}-dev"
release-dir: ./release
- name: Rename files
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
working-directory: release
run: for f in *; do mv "$f" "$(echo "$f" | sed s/-dev-/-/)"; done
- name: Delete dev release
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
uses: actions/delete-release@v1
with:
release_name: "${{ forgejo.ref_name }}-dev"
- name: Check previous release
id: check_prev
run: |
git fetch --tags
[[ ${{ forgejo.ref_type }} == 'tag' ]] && skip=0 || skip=1
last_tag=$(git describe --abbrev=0 --tags $(git rev-list --tags --skip=${skip} --max-count=1)) || true
echo "last_tag=${last_tag}" >> "$FORGEJO_OUTPUT"
- uses: actions/forgejo-release@v2.11.3
if: ${{ forgejo.ref_type == 'tag' && steps.check.outputs.exists == 'true' }}
with:
direction: upload
token: ${{ secrets.RELEASE_TOKEN }}
tag: ${{ forgejo.ref_name }}
override: false
prerelease: false
release-dir: ./release
release-notes: "Full Changelog: [${{ steps.check_prev.outputs.last_tag }}...${{ steps.version.outputs.v }}](https://code.gri.mw/${{ forgejo.repository }}/compare/${{ steps.check_prev.outputs.last_tag }}...${{ steps.version.outputs.v }})"
android:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: macos
needs: version
steps:
- uses: actions/checkout@v6
- name: Checkout submodules
run: |
sed -i -- 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
git submodule update --init --recursive --remote
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-android-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build libs
run: |
rustup -q update
chmod +x scripts/android.sh && ./scripts/android.sh lib ${{ needs.version.outputs.v }}
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: Restore gradle cache
id: cache-gradle-restore
uses: actions/cache/restore@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: grim-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Setup build
run: |
chmod +x android/gradlew
echo "${{ secrets.ANDROID_KEYSTORE }}" > release.keystore.txt
base64 -d < release.keystore.txt -o android/keystore
echo "${{ secrets.ANDROID_KEYSTORE_PROPS }}" > release.keystore.props.txt
base64 -d < release.keystore.props.txt -o android/keystore.properties
mkdir ~/.gradle && touch ~/.gradle/gradle.properties
printf "mavenHost=${{ secrets.MAVEN_LOCAL_HOST }}\n" >> ~/.gradle/gradle.properties
printf "mavenUser=${{ secrets.MAVEN_USER }}\n" >> ~/.gradle/gradle.properties
printf "mavenPassword=${{ secrets.MAVEN_PASSWORD }}" >> ~/.gradle/gradle.properties
- name: Release ARMv7+v8 APK
working-directory: android
run: |
jni_path=app/src/main/jniLibs
mv ${jni_path}/x86_64 x86_64
./gradlew assembleCiSignedRelease
apk_path=app/build/outputs/apk/ci/signedRelease/app-ci-signedRelease.apk
name=grim-${{ needs.version.outputs.v }}-android.apk
mv ${apk_path} "${name}"
- name: Checksum ARM APK
working-directory: android
run: |
name=grim-${{ needs.version.outputs.v }}-android.apk
checksum=grim-${{ needs.version.outputs.v }}-android-sha256sum.txt
sha256sum "${name}" > "${checksum}"
- name: Release x86_64 APK
working-directory: android
run: |
./gradlew clean
jni_path=app/src/main/jniLibs
rm -rf ${jni_path}/*
mv x86_64 ${jni_path}
./gradlew assembleCiSignedRelease
apk_path=app/build/outputs/apk/ci/signedRelease/app-ci-signedRelease.apk
name=grim-${{ needs.version.outputs.v }}-android-x86_64.apk
mv ${apk_path} "${name}"
- name: Save gradle cache
uses: actions/cache/save@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
- name: Checksum x86_64 APK
working-directory: android
run: |
name=grim-${{ needs.version.outputs.v }}-android.apk
checksum=grim-${{ needs.version.outputs.v }}-android-x86_64-sha256sum.txt
sha256sum "${name}" > "${checksum}"
- name: Upload artifacts
run: |
mkdir release
mv android/grim* release
tar -czf android.tar.gz release
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file android.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/android.tar.gz
linux_arm:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: debian-rust-arm
needs: [version]
steps:
- uses: actions/checkout@v6
- name: Checkout submodules
run: |
sed -i 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
git submodule update --init --recursive --remote
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
- name: Release Linux ARM
run: |
rustup -q update
cargo zigbuild --release --target aarch64-unknown-linux-gnu
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: Upload artifacts
run: |
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file target/aarch64-unknown-linux-gnu/release/grim ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/grim-linux-arm
linux_arm_appimage:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: debian-arm
needs: [version, linux_arm]
steps:
- uses: actions/checkout@v6
- name: Download Artifact
run: |
wget ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/grim-linux-arm
- name: AppImage
shell: bash
run: |
mkdir release
chmod +x grim-linux-arm
mv grim-linux-arm linux/Grim.AppDir/AppRun
ARCH=aarch64 appimagetool linux/Grim.AppDir grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
mv grim-${{ needs.version.outputs.v }}-linux-arm.AppImage release/
- name: Checksum AppImage ARM
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-linux-arm.AppImage > grim-${{ needs.version.outputs.v }}-linux-arm-appimage-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf linux-arm.appimage.tar.gz release
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-arm.appimage.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-arm.appimage.tar.gz
linux_x86:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: debian-rust-x86_64
needs: [version]
steps:
- uses: actions/checkout@v6
- name: Checkout submodules
run: |
sed -i 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
git submodule update --init --recursive --remote
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
- name: Release Linux x86
run: |
rustup -q update
cargo zigbuild --release --target x86_64-unknown-linux-gnu
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: AppImage x86
run: |
mkdir release
mv target/x86_64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
appimagetool linux/Grim.AppDir grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
mv grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage release/
- name: Checksum AppImage x86
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage > grim-${{ needs.version.outputs.v }}-linux-x86_64-appimage-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf linux-x86_64.appimage.tar.gz release
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-x86_64.appimage.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-x86_64.appimage.tar.gz
macos:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: macos
needs: [version, android]
steps:
- uses: actions/checkout@v6
- name: Checkout submodules
run: |
sed -i -- 's#https://code\.gri\.mw#${{ secrets.REPO_HOST }}#g' .gitmodules
git submodule update --init --recursive --remote
- run: mkdir release
- name: Restore cargo cache
id: cache-cargo-restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-macos-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Release MacOS Universal
run: |
rustup -q update
export MACOSX_DEPLOYMENT_TARGET=11.0
cargo build --release --target x86_64-apple-darwin
cargo build --release --target aarch64-apple-darwin
lipo -create -output grim "target/x86_64-apple-darwin/release/grim" "target/aarch64-apple-darwin/release/grim"
mv grim macos/Grim.app/Contents/MacOS
- name: Save cargo cache
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: Archive Universal
working-directory: macos
run: |
zip -r grim-${{ needs.version.outputs.v }}-macos-universal.zip Grim.app
mv grim-${{ needs.version.outputs.v }}-macos-universal.zip ../release
- name: Checksum Release Universal
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-macos-universal.zip > grim-${{ needs.version.outputs.v }}-macos-universal-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf macos.tar.gz release
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file macos.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/macos.tar.gz
windows:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: windows
needs: [version]
steps:
- uses: actions/checkout@v6
- name: Checkout submodules
run: |
(Get-content .gitmodules) | Foreach-Object {$_ -replace "https://code.gri.mw", "${{ secrets.REPO_HOST }}"} | Set-Content .gitmodules
git submodule update --init --recursive --remote
- name: Update UpgradeCode
shell: powershell
run: |
$guid = [guid]::NewGuid().ToString()
$wix = [xml](Get-Content wix/main.wxs)
$wix.Wix.Product.UpgradeCode = $guid
$wix.Save("wix/main.wxs")
Get-Content wix/main.wxs
- run: mkdir release
- name: Release Windows x86
run: |
rustup -q update
cargo wix -p grim -o grim-${{ needs.version.outputs.v }}-win-x86_64.msi --nocapture
mv grim-${{ needs.version.outputs.v }}-win-x86_64.msi release\
Compress-Archive -Path target\release\grim.exe -DestinationPath grim-${{ needs.version.outputs.v }}-win-x86_64.zip
mv grim-${{ needs.version.outputs.v }}-win-x86_64.zip release\
- name: Checksum Archive x86
working-directory: release
run: |
certutil -hashfile grim-${{ needs.version.outputs.v }}-win-x86_64.msi SHA256 > grim-${{ needs.version.outputs.v }}-win-x86_64-msi-sha256sum.txt
certutil -hashfile grim-${{ needs.version.outputs.v }}-win-x86_64.zip SHA256 > grim-${{ needs.version.outputs.v }}-win-x86_64-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf windows.tar.gz release
Remove-Item alias:curl
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file windows.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/windows.tar.gz
release:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
runs-on: debian-release
needs: [version, android, linux_x86, linux_arm_appimage, macos, windows]
steps:
- name: Download All Artifacts
run: |
curl -s -o android.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/android.tar.gz
tar -xzf android.tar.gz
rm android.tar.gz
curl -s -o linux-x86_64.appimage.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-x86_64.appimage.tar.gz
tar -xzf linux-x86_64.appimage.tar.gz
rm linux-x86_64.appimage.tar.gz
curl -s -o linux-arm.appimage.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-arm.appimage.tar.gz
tar -xzf linux-arm.appimage.tar.gz
rm linux-arm.appimage.tar.gz
curl -s -o macos.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/macos.tar.gz
tar -xzf macos.tar.gz
rm macos.tar.gz
curl -s -o windows.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/windows.tar.gz
tar -xzf windows.tar.gz
rm windows.tar.gz
- name: Upload release to Forgejo
uses: actions/forgejo-release@v2.11.3
with:
direction: upload
token: ${{ secrets.RELEASE_TOKEN }}
tag: ${{ needs.version.outputs.v }}
override: true
prerelease: ${{ needs.version.outputs.pre == 'true' }}
release-dir: release
release-notes: "Full Changelog: [${{ needs.version.outputs.last_tag }}...${{ needs.version.outputs.v }}](https://code.gri.mw/${{ forgejo.repository }}/compare/${{ needs.version.outputs.last_tag }}...${{ needs.version.outputs.v }})"
- name: Telegram Notify Channel
if: always()
uses: actions/telegram-notifier@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_CHANNEL_ID }}
status: ${{ job.status }}
notify_fields: "actor,repository,workflow,branch,commit"
- name: Telegram Notify Group
if: always()
uses: actions/telegram-notifier@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_GROUP_ID }}
status: ${{ job.status }}
notify_fields: "actor,repository,workflow,branch,commit"
release-telegram:
runs-on: debian-release
needs: [version, release]
steps:
- name: Download All Artifacts
run: |
mkdir release
cd release
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-android.apk
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-android-x86_64.apk
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-macos-universal.zip
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-win-x86_64.msi
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-win-x86_64.zip
- name: Upload files to Telegram
uses: actions/telegram-send-file@main
with:
api_url: ${{ secrets.TELEGRAM_API_URL }}
chat_ids: |
${{ secrets.TELEGRAM_CHANNEL_ID }}
${{ secrets.TELEGRAM_GROUP_ID }}
body: '🎁 Release <a href="https://code.gri.mw/${{ forgejo.repository }}/releases">${{ needs.version.outputs.v }}</a> is ready!'
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
pin: true
files: |
release/grim-${{ needs.version.outputs.v }}-android.apk
release/grim-${{ needs.version.outputs.v }}-android-x86_64.apk
release/grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
release/grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
release/grim-${{ needs.version.outputs.v }}-macos-universal.zip
release/grim-${{ needs.version.outputs.v }}-win-x86_64.msi
release/grim-${{ needs.version.outputs.v }}-win-x86_64.zip
+23
View File
@@ -0,0 +1,23 @@
#!/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
+9 -40
View File
@@ -2,48 +2,13 @@ name: Build
on: [push, pull_request] on: [push, pull_request]
jobs: jobs:
android:
name: Android Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Setup build
run: |
cargo install cargo-ndk
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
- name: Setup Java build
run: |
chmod +x android/gradlew
echo "${{ secrets.ANDROID_RELEASE_KEYSTORE }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.ANDROID_RELEASE_SECRET }}" --batch release.keystore.asc > android/keystore
echo -e "storePassword=${{ secrets.ANDROID_PASS }}\nkeyPassword=${{ secrets.ANDROID_PASS }}\nkeyAlias=grim\nstoreFile=../keystore" > android/keystore.properties
- name: Build lib 1/2
continue-on-error: true
run: |
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t arm64-v8a build --profile release-apk
- name: Build lib 2/2
run: |
unset CPPFLAGS && unset CFLAGS && cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --profile release-apk
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
- name: Build APK
working-directory: android
run: |
./gradlew assembleRelease
linux: linux:
name: Linux Build name: Linux Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with:
submodules: recursive
- name: Release build - name: Release build
run: cargo build --release run: cargo build --release
@@ -51,7 +16,9 @@ jobs:
name: Windows Build name: Windows Build
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with:
submodules: recursive
- name: Release build - name: Release build
run: cargo build --release run: cargo build --release
@@ -59,6 +26,8 @@ jobs:
name: MacOS Build name: MacOS Build
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with:
submodules: recursive
- name: Release build - name: Release build
run: cargo build --release run: cargo build --release
+16 -245
View File
@@ -6,255 +6,26 @@ on:
- "v*.*.*" - "v*.*.*"
jobs: jobs:
android_release: create_release:
name: Android Release name: Create Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: - name: Download release
fetch-depth: 0 run: chmod +x .github/download_release.sh && .github/download_release.sh GUI/grim ${{ github.ref_name }}
- name: Setup JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Setup Rust build
run: |
cargo install cargo-ndk
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
- name: Setup Java build
run: |
chmod +x android/gradlew
echo "${{ secrets.ANDROID_RELEASE_KEYSTORE }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.ANDROID_RELEASE_SECRET }}" --batch release.keystore.asc > android/keystore
echo -e "storePassword=${{ secrets.ANDROID_PASS }}\nkeyPassword=${{ secrets.ANDROID_PASS }}\nkeyAlias=grim\nstoreFile=../keystore" > android/keystore.properties
- name: Build lib ARMv8 1/2
continue-on-error: true
run: |
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t arm64-v8a build --profile release-apk
- name: Build lib ARMv8 2/2
run: |
unset CPPFLAGS && unset CFLAGS && cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --profile release-apk
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
- name: Build APK ARMv8
working-directory: android
run: |
./gradlew assembleRelease
mv app/build/outputs/apk/release/app-release.apk grim-${{ github.ref_name }}-android-armv8.apk
- name: Checksum APK ARMv8
working-directory: android
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-android-armv8.apk | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-android-armv8-sha256sum.txt
- name: Build lib ARMv7 1/2
continue-on-error: true
run: |
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t armeabi-v7a build --profile release-apk
- name: Build lib ARMv7 2/2
run: |
unset CPPFLAGS && unset CFLAGS && cargo ndk -t armeabi-v7a -o android/app/src/main/jniLibs build --profile release-apk
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
- name: Build APK ARMv7
working-directory: android
run: |
rm -rf app/build
rm -rf app/src/main/jniLibs/*
./gradlew assembleRelease
mv app/build/outputs/apk/release/app-release.apk grim-${{ github.ref_name }}-android-armv7.apk
- name: Checksum APK ARMv7
working-directory: android
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-android-armv7.apk | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-android-armv7-sha256sum.txt
- name: Build lib x86 1/2
continue-on-error: true
run: |
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0" && cargo ndk -t x86_64 build --profile release-apk
- name: Build lib x86 2/2
run: |
unset CPPFLAGS && unset CFLAGS && cargo ndk -t x86_64 -o android/app/src/main/jniLibs build --profile release-apk
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml
- name: Build APK x86
working-directory: android
run: |
rm -rf app/build
rm -rf app/src/main/jniLibs/*
./gradlew assembleRelease
mv app/build/outputs/apk/release/app-release.apk grim-${{ github.ref_name }}-android-x86_64.apk
- name: Checksum APK x86
working-directory: android
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-android-x86_64.apk | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-android-x86_64-sha256sum.txt
- name: Release - name: Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
with: with:
body_path: release_notes.txt
overwrite_files: true
files: | files: |
android/grim-${{ github.ref_name }}-android-armv8.apk grim-${{ github.ref_name }}-android.apk
android/grim-${{ github.ref_name }}-android-armv8-sha256sum.txt grim-${{ github.ref_name }}-android-x86_64.apk
android/grim-${{ github.ref_name }}-android-armv7.apk grim-${{ github.ref_name }}-linux-arm.AppImage
android/grim-${{ github.ref_name }}-android-armv7-sha256sum.txt grim-${{ github.ref_name }}-linux-x86_64.AppImage
android/grim-${{ github.ref_name }}-android-x86_64.apk grim-${{ github.ref_name }}-macos-universal.zip
android/grim-${{ github.ref_name }}-android-x86_64-sha256sum.txt grim-${{ github.ref_name }}-win-x86_64.msi
grim-${{ github.ref_name }}-win-x86_64.zip
linux_release:
name: Linux Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download appimagetools
run: |
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
sudo apt install libfuse2
- name: Zig Setup
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.12.1
- name: Install cargo-zigbuild
run: cargo install cargo-zigbuild
- name: Release x86
run: cargo zigbuild --release --target x86_64-unknown-linux-gnu
- name: Release ARM
run: |
rustup target add aarch64-unknown-linux-gnu
cargo zigbuild --release --target aarch64-unknown-linux-gnu
- name: AppImage x86
run: |
cp target/x86_64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
./appimagetool-x86_64.AppImage linux/Grim.AppDir target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64.AppImage
- name: Checksum AppImage x86
working-directory: target/x86_64-unknown-linux-gnu/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-linux-x86_64.AppImage | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-linux-x86_64-appimage-sha256sum.txt
- name: AppImage ARM
run: |
cp target/aarch64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
./appimagetool-x86_64.AppImage linux/Grim.AppDir target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm.AppImage
- name: Checksum AppImage ARM
working-directory: target/aarch64-unknown-linux-gnu/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-linux-arm.AppImage | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-linux-arm-appimage-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64.AppImage
target/x86_64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-x86_64-appimage-sha256sum.txt
target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm.AppImage
target/aarch64-unknown-linux-gnu/release/grim-${{ github.ref_name }}-linux-arm-appimage-sha256sum.txt
windows_release:
name: Windows Release
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: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-win-x86_64.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-win-x86_64-sha256sum.txt
- name: Install cargo-wix
run: cargo install cargo-wix
- name: Run cargo-wix
run: cargo wix -p grim -o ./target/wix/grim-${{ github.ref_name }}-win-x86_64.msi --nocapture
- name: Checksum msi
working-directory: target/wix
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-win-x86_64.msi | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-win-x86_64-msi-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
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-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Zig Setup
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.12.1
- name: Install cargo-zigbuild
run: cargo install cargo-zigbuild
- name: Release x86
run: |
rustup target add x86_64-apple-darwin
cargo zigbuild --release --target x86_64-apple-darwin
mkdir macos/Grim.app/Contents/MacOS
yes | cp -rf target/x86_64-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive x86
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-x86_64.zip Grim.app
mv grim-${{ github.ref_name }}-macos-x86_64.zip ../target/x86_64-apple-darwin/release
cd ..
- name: Checksum Release x86
working-directory: target/x86_64-apple-darwin/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-x86_64.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-x86_64-sha256sum.txt
- name: Release ARM
run: |
rustup target add aarch64-apple-darwin
cargo zigbuild --release --target aarch64-apple-darwin
yes | cp -rf target/aarch64-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive ARM
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-arm.zip Grim.app
mv grim-${{ github.ref_name }}-macos-arm.zip ../target/aarch64-apple-darwin/release
cd ..
- name: Checksum Release ARM
working-directory: target/aarch64-apple-darwin/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-arm.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-arm-sha256sum.txt
- name: Release Universal
run: |
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
cargo zigbuild --release --target universal2-apple-darwin
yes | cp -rf target/universal2-apple-darwin/release/grim macos/Grim.app/Contents/MacOS
- name: Archive Universal
run: |
cd macos
zip -r grim-${{ github.ref_name }}-macos-universal.zip Grim.app
mv grim-${{ github.ref_name }}-macos-universal.zip ../target/universal2-apple-darwin/release
cd ..
- name: Checksum Release Universal
working-directory: target/universal2-apple-darwin/release
shell: pwsh
run: get-filehash -algorithm sha256 grim-${{ github.ref_name }}-macos-universal.zip | Format-List | Out-String | ForEach-Object { $_.Trim() } > grim-${{ github.ref_name }}-macos-universal-sha256sum.txt
- name: Release
uses: softprops/action-gh-release@v1
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
+170
View File
@@ -0,0 +1,170 @@
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
+6 -2
View File
@@ -1,9 +1,13 @@
*.iml *.iml
android/build
android/.idea
android/.gradle android/.gradle
android/local.properties android/local.properties
android/keystore android/keystore
android/keystore.asc android/keystore.asc
android/keystore.properties android/keystore.properties
android/*.apk
android/*sha256sum.txt
/.idea /.idea
.DS_Store .DS_Store
/captures /captures
@@ -13,7 +17,7 @@ android/keystore.properties
target target
.cargo/ .cargo/
app/src/main/jniLibs app/src/main/jniLibs
macos/Grim.app/Contents/MacOS/grim
macos/cert.pem macos/cert.pem
linux/Grim.AppDir/AppRun linux/Grim.AppDir/AppRun
.intentionally-empty-file.o .intentionally-empty-file.o
Cargo.toml-e
+8
View File
@@ -0,0 +1,8 @@
[submodule "wallet"]
path = wallet
url = https://code.gri.mw/ardocrat/wallet
branch = grim-staging
[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
+5423 -3874
View File
File diff suppressed because it is too large Load Diff
+85 -84
View File
@@ -1,12 +1,13 @@
[package] [package]
name = "grim" name = "grim"
version = "0.1.0" version = "0.3.6"
authors = ["Ardocrat <ardocrat@proton.me>"] authors = ["Ardocrat <ardocrat@gri.mw>"]
description = "Cross-platform GUI for Grin with focus on usability and availability to be used by anyone, anywhere." description = "Cross-platform GUI for Grin with focus on usability and availability to be used by anyone, anywhere."
license = "Apache-2.0" license = "Apache-2.0"
repository = "https://github.com/ardocrat/grim" repository = "https://code.gri.mw/GUI/grim"
keywords = [ "crypto", "grin", "mimblewimble" ] keywords = [ "crypto", "grin", "mimblewimble" ]
edition = "2021" edition = "2024"
build = "build.rs"
[[bin]] [[bin]]
name = "grim" name = "grim"
@@ -16,9 +17,6 @@ path = "src/main.rs"
name="grim" name="grim"
crate-type = ["rlib"] crate-type = ["rlib"]
[profile.release]
debug = 1
[profile.release-apk] [profile.release-apk]
inherits = "release" inherits = "release"
strip = true strip = true
@@ -28,108 +26,111 @@ codegen-units = 1
panic = "abort" panic = "abort"
[dependencies] [dependencies]
log = "0.4" log = "0.4.27"
## node # node
openssl-sys = { version = "0.9.82", features = ["vendored"] } grin_api = { path = "wallet/grin/api" }
grin_api = "5.3.1" grin_chain = { path = "wallet/grin/chain" }
grin_chain = "5.3.1" grin_config = { path = "wallet/grin/config" }
grin_config = "5.3.1" grin_core = { path = "wallet/grin/core" }
grin_core = "5.3.1" grin_p2p = { path = "wallet/grin/p2p" }
grin_p2p = "5.3.1" grin_servers = { path = "wallet/grin/servers" }
grin_servers = "5.3.1" grin_keychain = { path = "wallet/grin/keychain" }
grin_keychain = "5.3.1" grin_util = { path = "wallet/grin/util" }
grin_util = "5.3.1"
## wallet # wallet
grin_wallet_impls = "5.3.1" grin_wallet_impls = { path = "wallet/impls" }
grin_wallet_api = "5.3.1" grin_wallet_api = { path = "wallet/api"}
grin_wallet_libwallet = "5.3.1" grin_wallet_libwallet = { path = "wallet/libwallet" }
grin_wallet_util = "5.3.1" grin_wallet_util = { path = "wallet/util" }
grin_wallet_controller = "5.3.1" grin_wallet_controller = { path = "wallet/controller" }
## ui ## ui
egui = { version = "0.28.1", default-features = false } egui = { version = "0.33.3", default-features = false }
egui_extras = { version = "0.28.1", features = ["image", "svg"] } egui_extras = { version = "0.33.3", features = ["image", "svg"] }
rust-i18n = "2.3.1" egui-async = "0.3.4"
rust-i18n = "3.1.5"
## other ## other
backtrace = "0.3" log4rs = "1.4.0"
panic-message = "0.3.0" backtrace = "0.3.76"
thiserror = "1.0.58" futures = "0.3.31"
futures = "0.3" dirs = "6.0.0"
dirs = "5.0.1" sys-locale = "0.3.2"
sys-locale = "0.3.0" chrono = "0.4.43"
chrono = "0.4.31" parking_lot = "0.12.3"
parking_lot = "0.12.1" lazy_static = "1.5.0"
lazy_static = "1.4.0" toml = "0.9.11+spec-1.1.0"
toml = "0.8.2" serde = "1.0.228"
serde = "1.0.170" local-ip-address = "0.6.9"
local-ip-address = "0.6.1" url = "2.5.8"
url = "2.4.0" rand = "0.9.2"
rand = "0.8.5" serde_derive = "1.0.228"
serde_derive = "1.0.197" serde_json = "1.0.149"
serde_json = "1.0.115" tokio = { version = "1.49.0", features = ["full"] }
tokio = { version = "1.37.0", features = ["full"] } image = "0.25.9"
image = "0.25.1" rqrr = "0.10.1"
rqrr = "0.7.1"
qrcodegen = "1.8.0" qrcodegen = "1.8.0"
qrcode = "0.14.0" qrcode = "0.14.1"
ur = "0.4.1" ur = "0.4.1"
gif = "0.13.1" gif = "0.14.1"
rkv = { version = "0.19.0", features = ["lmdb"] } rkv = "0.20.0"
usvg = "0.45.1"
ring = "0.16.20"
hyper = { version = "1.6.0", features = ["full"], package = "hyper" }
hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy"] }
http-body-util = "0.1.3"
bytes = "1.11.0"
hyper-socks2 = "0.9.1"
hyper-proxy2 = "0.1.0"
hyper-tls = "0.6.0"
async-std = "1.13.2"
uuid = { version = "0.8.2", features = ["v4"] }
num-bigint = "0.4.6"
## tor ## tor
arti-client = { version = "0.19.0", features = ["pt-client", "static", "onion-service-service", "onion-service-client"] } arti-client = { version = "0.43.0", features = ["static", "pt-client", "onion-service-service", "onion-service-client"] }
tor-rtcompat = { version = "0.19.0", features = ["static"] } tor-rtcompat = { version = "0.43.0", features = ["static"] }
tor-config = "0.19.0" tor-config = "0.43.0"
fs-mistrust = "0.7.9" fs-mistrust = "0.14.2"
tor-hsservice = "0.19.0" tor-hsservice = "0.43.0"
tor-hsrproxy = "0.19.0" tor-hsrproxy = "0.43.0"
tor-keymgr = "0.19.0" tor-keymgr = "0.43.0"
tor-llcrypto = "0.19.0" tor-llcrypto = "0.43.0"
tor-hscrypto = "0.19.0" tor-hscrypto = "0.43.0"
arti-hyper = "0.19.0" sha2 = "0.10.8"
sha2 = "0.10.0"
ed25519-dalek = "2.1.1" ed25519-dalek = "2.1.1"
curve25519-dalek = "4.1.2" curve25519-dalek = "4.1.3"
hyper = { version = "0.14.28", features = ["full"] } safelog = "0.8.1"
hyper-tls = "0.5.0"
tls-api = "0.9.0"
tls-api-native-tls = "0.9.0"
## stratum server ## stratum server
tokio-old = {version = "0.2", features = ["full"], package = "tokio" } tokio-old = { version = "0.2", features = ["full"], package = "tokio" }
tokio-util-old = { version = "0.2", features = ["codec"], package = "tokio-util" } tokio-util-old = { version = "0.2", features = ["codec"], package = "tokio-util" }
[target.'cfg(all(not(target_os = "windows"), not(target_os = "android")))'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
eye = { version = "0.5.0", default-features = false } nokhwa = { version = "0.10.10", default-features = false, features = ["input-v4l"] }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
nokhwa = { version = "0.10.4", default-features = false, features = ["input-msmf"] } nokhwa = { version = "0.10.10", default-features = false, features = ["input-msmf"] }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tls-api-openssl = "0.9.0" nokhwa = { version = "0.10.10", default-features = false, features = ["input-avfoundation", "output-threaded"] }
openpnp_capture_sys = "0.4.0"
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
env_logger = "0.11.3" env_logger = "0.11.3"
winit = { version = "0.29.15" } winit = { version = "0.30.12" }
eframe = { version = "0.28.1", features = ["wgpu", "glow"] } wgpu = { version = "27.0.1" }
eframe = { version = "0.33.2", features = ["wgpu"] }
arboard = "3.2.0" arboard = "3.2.0"
rfd = "0.14.1" rfd = "0.17.2"
dark-light = "1.1.1" interprocess = { version = "2.2.1", features = ["tokio"] }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.13.1" android_logger = "0.15.0"
jni = "0.21.1" jni = "0.21.1"
android-activity = { version = "0.6.0", features = ["game-activity"] } android-activity = { version = "0.6.0", features = ["game-activity"] }
wgpu = "0.20.1" winit = { version = "0.30.12", features = ["android-game-activity"] }
winit = { version = "0.29.15", features = ["android-game-activity"] } eframe = { version = "0.33.2", default-features = false, features = ["glow", "android-game-activity"] }
eframe = { version = "0.28.1", features = ["wgpu", "android-game-activity"] }
[patch.crates-io] [build-dependencies]
### patch grin store built = "0.8.0"
#grin_store = { path = "../grin-store" }
### fix cross-compilation support for macos
openpnp_capture_sys = { git = "https://github.com/ardocrat/openpnp-capture-rs", branch = "cross_compilation_support" }
+4 -3
View File
@@ -1,11 +1,11 @@
# <img height="22" src="https://github.com/ardocrat/grim/blob/master/android/app/src/main/ic_launcher-playstore.png?raw=true"> Grim <img height="20" src="https://github.com/mimblewimble/site/blob/master/assets/images/grin-logo.png?raw=true"> <img height="20" src="https://github.com/ardocrat/grim/blob/master/img/logo.png?raw=true"> # 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/) 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. 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. 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.
Named by the character [Grim](http://harrypotter.wikia.com/wiki/Grim) - the shape of a large, black, menacing, spectral giant dog. Named by the character [Grim](http://harrypotter.wikia.com/wiki/Grim) - the shape of a large, black, menacing, spectral giant dog.
![image](https://github.com/user-attachments/assets/a925b1c8-02c9-4b08-b888-0315d11138b6) ![image](img/cover.png)
## Build instructions ## Build instructions
@@ -20,6 +20,7 @@ Follow instructions on [Windows](https://forge.rust-lang.org/infra/other-install
To build and run application go to project directory and run: To build and run application go to project directory and run:
``` ```
git submodule update --init --recursive
cargo build --release cargo build --release
./target/release/grim ./target/release/grim
``` ```
@@ -31,7 +32,7 @@ Install Android SDK / NDK / Platform Tools for your OS according to this [FAQ](h
#### Build the project #### Build the project
Run Android emulator or connect a real device. Command `adb devices` should show at least one device. Run Android emulator or connect a real device. Command `adb devices` should show at least one device.
In the root of the repo run `./scripts/build_run_android.sh debug|release v7|v8`, where is `v7`, `v8` - device CPU architecture. 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`.
## License ## License
+77 -24
View File
@@ -2,36 +2,49 @@ plugins {
id 'com.android.application' id 'com.android.application'
} }
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android { android {
compileSdk 33 compileSdk = 36
ndkVersion '26.0.10792818' ndkVersion '29.0.14206865'
buildToolsVersion = '36.1.0'
defaultConfig { defaultConfig {
applicationId "mw.gri.android" applicationId "mw.gri.android"
minSdk 24 minSdk 24
targetSdk 33 targetSdk 36
versionCode 1 versionCode 5
versionName "0.1.0" versionName "0.3.6"
} }
signingConfigs { lint {
release { checkReleaseBuilds false
keyAlias keystoreProperties['keyAlias'] }
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile']) def keystorePropertiesFile = rootProject.file("keystore.properties")
storePassword keystoreProperties['storePassword'] def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
} }
} }
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release }
if (keystorePropertiesFile.exists()) {
signedRelease {
initWith release
signingConfig signingConfigs.release
}
} }
debug { debug {
minifyEnabled false minifyEnabled false
@@ -39,21 +52,61 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
} }
namespace 'mw.gri.android' namespace 'mw.gri.android'
flavorDimensions "mode"
productFlavors {
ci {
dimension "mode"
}
local {
dimension "mode"
}
}
applicationVariants.all { variant ->
def flavor = variant.productFlavors[0].name
if (flavor == "ci") {
repositories {
maven {
credentials {
username "$mavenUser"
password "$mavenPassword"
}
url "$mavenHost/repository/maven-central/"
allowInsecureProtocol = true
}
maven {
credentials {
username "$mavenUser"
password "$mavenPassword"
}
url "$mavenHost/repository/google-android-maven/"
allowInsecureProtocol = true
}
}
} else if (flavor == "local") {
repositories {
google()
mavenCentral()
}
}
}
} }
dependencies { dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.7.0'
// To use the Games Activity library // To use the Games Activity library
implementation "androidx.games:games-activity:2.0.2" implementation "androidx.games:games-activity:2.0.2"
// Android Camera // Android Camera
implementation 'androidx.camera:camera-core:1.2.3' implementation 'androidx.camera:camera-core:1.5.1'
implementation 'androidx.camera:camera-camera2:1.2.3' implementation 'androidx.camera:camera-camera2:1.5.1'
implementation 'androidx.camera:camera-lifecycle:1.2.3' implementation 'androidx.camera:camera-lifecycle:1.5.1'
} }
+41 -23
View File
@@ -1,52 +1,70 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android">
>
<uses-feature android:name="android.hardware.camera" android:required="false"/> <uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-permission android:name="android.permission.EXPAND_STATUS_BAR" /> <uses-permission android:name="android.permission.EXPAND_STATUS_BAR" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <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.WAKE_LOCK" />
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<application <application
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:largeHeap="true" android:largeHeap="true"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="Grim" android:label="Grim"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:theme="@style/Theme.Main"
android:theme="@style/Theme.Main"> android:enableOnBackInvokedCallback="false"
android:extractNativeLibs="true"
tools:ignore="UnusedAttribute">
<receiver android:name=".NotificationActionsReceiver"/> <receiver android:name=".NotificationActionsReceiver"/>
<provider <provider
android:name=".FileProvider" android:name=".FileProvider"
android:authorities="mw.gri.android.fileprovider" android:authorities="mw.gri.android.fileprovider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/paths" /> android:resource="@xml/paths" />
</provider> </provider>
<activity <activity
android:launchMode="singleTask" android:launchMode="singleTask"
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize" android:configChanges="orientation|screenSize"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter android:scheme="http" tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="application/*" />
<data android:pathPattern=".*\\.slatepack" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="grim" /> <meta-data android:name="android.app.lib_name" android:value="grim" />
</activity> </activity>
<service android:name=".BackgroundService" android:stopWithTask="true" />
<service
android:name=".BackgroundService"
android:stopWithTask="true"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>
@@ -2,13 +2,13 @@ package mw.gri.android;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.*; import android.app.*;
import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.os.*; import android.os.*;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import java.util.List; import java.util.List;
@@ -16,7 +16,7 @@ import static android.app.Notification.EXTRA_NOTIFICATION_ID;
public class BackgroundService extends Service { public class BackgroundService extends Service {
private static final String TAG = BackgroundService.class.getSimpleName(); private static final String TAG = BackgroundService.class.getSimpleName();
private PowerManager.WakeLock mWakeLock; private PowerManager.WakeLock mWakeLock;
private final Handler mHandler = new Handler(Looper.getMainLooper()); private final Handler mHandler = new Handler(Looper.getMainLooper());
@@ -31,26 +31,6 @@ public class BackgroundService extends Service {
public static final String ACTION_START_NODE = "start_node"; public static final String ACTION_START_NODE = "start_node";
public static final String ACTION_STOP_NODE = "stop_node"; public static final String ACTION_STOP_NODE = "stop_node";
public static final String ACTION_EXIT = "exit";
public static final String ACTION_REFRESH = "refresh";
public static final String ACTION_STOP = "stop";
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@SuppressLint("RestrictedApi")
@Override
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals(ACTION_STOP)) {
mStopped = true;
// Remove actions buttons.
mNotificationBuilder.mActions.clear();
NotificationManager manager = getSystemService(NotificationManager.class);
manager.notify(NOTIFICATION_ID, mNotificationBuilder.build());
} else {
mHandler.removeCallbacks(mUpdateSyncStatus);
mHandler.post(mUpdateSyncStatus);
}
}
};
private final Runnable mUpdateSyncStatus = new Runnable() { private final Runnable mUpdateSyncStatus = new Runnable() {
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@@ -101,18 +81,6 @@ public class BackgroundService extends Service {
.getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); .getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
mNotificationBuilder.addAction(R.drawable.ic_stop, getStopText(), i); mNotificationBuilder.addAction(R.drawable.ic_stop, getStopText(), i);
} }
// Set up a button to exit from the app.
if (canStart || canStop) {
Intent exitIntent = new Intent(BackgroundService.this, NotificationActionsReceiver.class);
if (Build.VERSION.SDK_INT > 25) {
exitIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID);
}
exitIntent.setAction(ACTION_EXIT);
PendingIntent i = PendingIntent
.getBroadcast(BackgroundService.this, 1, exitIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
mNotificationBuilder.addAction(R.drawable.ic_close, getExitText(), i);
}
} }
// Update notification. // Update notification.
@@ -152,13 +120,17 @@ public class BackgroundService extends Service {
// Show notification with sync status. // Show notification with sync status.
Intent i = getPackageManager().getLaunchIntentForPackage(this.getPackageName()); Intent i = getPackageManager().getLaunchIntentForPackage(this.getPackageName());
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE); PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE);
mNotificationBuilder = new NotificationCompat.Builder(this, TAG) try {
.setContentTitle(this.getSyncTitle()) mNotificationBuilder = new NotificationCompat.Builder(this, TAG)
.setContentText(this.getSyncStatusText()) .setContentTitle(this.getSyncTitle())
.setStyle(new NotificationCompat.BigTextStyle().bigText(this.getSyncStatusText())) .setContentText(this.getSyncStatusText())
.setSmallIcon(R.drawable.ic_stat_name) .setStyle(new NotificationCompat.BigTextStyle().bigText(this.getSyncStatusText()))
.setPriority(NotificationCompat.PRIORITY_MAX) .setSmallIcon(R.drawable.ic_stat_name)
.setContentIntent(pendingIntent); .setPriority(NotificationCompat.PRIORITY_MAX)
.setContentIntent(pendingIntent);
} catch (UnsatisfiedLinkError e) {
return;
}
Notification notification = mNotificationBuilder.build(); Notification notification = mNotificationBuilder.build();
// Start service at foreground state to prevent killing by system. // Start service at foreground state to prevent killing by system.
@@ -166,9 +138,6 @@ public class BackgroundService extends Service {
// Update sync status at notification. // Update sync status at notification.
mHandler.post(mUpdateSyncStatus); mHandler.post(mUpdateSyncStatus);
// Register receiver to refresh notifications by intent.
registerReceiver(mReceiver, new IntentFilter(ACTION_REFRESH));
} }
@Override @Override
@@ -199,7 +168,6 @@ public class BackgroundService extends Service {
// Stop updating the notification. // Stop updating the notification.
mHandler.removeCallbacks(mUpdateSyncStatus); mHandler.removeCallbacks(mUpdateSyncStatus);
unregisterReceiver(mReceiver);
clearNotification(); clearNotification();
// Remove service from foreground state. // Remove service from foreground state.
@@ -222,12 +190,12 @@ public class BackgroundService extends Service {
} }
// Start the service. // Start the service.
public static void start(Context context) { public static void start(Context c) {
if (!isServiceRunning(context)) { if (!isServiceRunning(c)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(new Intent(context, BackgroundService.class)); ContextCompat.startForegroundService(c, new Intent(c, BackgroundService.class));
} else { } else {
context.startService(new Intent(context, BackgroundService.class)); c.startService(new Intent(c, BackgroundService.class));
} }
} }
} }
@@ -266,9 +234,6 @@ public class BackgroundService extends Service {
// Check if stop node is possible. // Check if stop node is possible.
private native boolean canStopNode(); private native boolean canStopNode();
// Get exit text for notification.
private native String getExitText();
// Check if app from the app is needed after node stop. // Check if app from the app is needed after node stop.
private native boolean exitAppAfterNodeStop(); private native boolean exitAppAfterNodeStop();
} }
@@ -7,15 +7,15 @@ import android.content.*;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.*;
import android.os.Bundle;
import android.os.Process; import android.os.Process;
import android.provider.Settings;
import android.system.ErrnoException; import android.system.ErrnoException;
import android.system.Os; import android.system.Os;
import android.util.Size; import android.util.Log;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.View; import android.view.View;
import android.view.inputmethod.InputMethodManager;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts; import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -28,9 +28,11 @@ import androidx.core.view.DisplayCutoutCompat;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
import com.google.androidgamesdk.GameActivity; import com.google.androidgamesdk.GameActivity;
import com.google.androidgamesdk.gametextinput.State;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import java.io.*; import java.io.*;
import java.util.Objects;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -50,15 +52,13 @@ public class MainActivity extends GameActivity {
private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
@Override @Override
public void onReceive(Context ctx, Intent i) { public void onReceive(Context ctx, Intent i) {
if (i.getAction().equals(STOP_APP_ACTION)) { if (Objects.equals(i.getAction(), STOP_APP_ACTION)) {
onExit(); exit();
Process.killProcess(Process.myPid());
} }
} }
}; };
private final ImageAnalysis mImageAnalysis = new ImageAnalysis.Builder() private final ImageAnalysis mImageAnalysis = new ImageAnalysis.Builder()
.setTargetResolution(new Size(640, 480))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build(); .build();
@@ -67,21 +67,30 @@ public class MainActivity extends GameActivity {
private ExecutorService mCameraExecutor = null; private ExecutorService mCameraExecutor = null;
private boolean mUseBackCamera = true; private boolean mUseBackCamera = true;
private ActivityResultLauncher<Intent> mFilePickResultLauncher = null; private ActivityResultLauncher<Intent> mFilePickResult = null;
private ActivityResultLauncher<Intent> mOpenFilePermissionsResult = null;
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag")
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
// Check if activity was launched to exclude from recent apps on exit.
if ((getIntent().getFlags() & Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) != 0) {
super.onCreate(null);
finish();
return;
}
// Clear cache on start. // Clear cache on start.
if (savedInstanceState == null) { if (savedInstanceState == null && getExternalCacheDir() != null) {
Utils.deleteDirectoryContent(new File(getExternalCacheDir().getPath()), false); Utils.deleteDirectoryContent(new File(getExternalCacheDir().getPath()), false);
} }
// Setup environment variables for native code. // Setup environment variables for native code.
try { try {
Os.setenv("HOME", getExternalFilesDir("").getPath(), true); Os.setenv("HOME", Objects.requireNonNull(getExternalFilesDir("")).getPath(), true);
Os.setenv("XDG_CACHE_HOME", getExternalCacheDir().getPath(), true); Os.setenv("XDG_CACHE_HOME", Objects.requireNonNull(getExternalCacheDir()).getPath(), true);
Os.setenv("ARTI_FS_DISABLE_PERMISSION_CHECKS", "true", true); Os.setenv("ARTI_FS_DISABLE_PERMISSION_CHECKS", "true", true);
Os.setenv("NATIVE_LIBS_DIR", getApplicationInfo().nativeLibraryDir, true);
} catch (ErrnoException e) { } catch (ErrnoException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
@@ -89,17 +98,30 @@ public class MainActivity extends GameActivity {
super.onCreate(null); super.onCreate(null);
// Register receiver to finish activity from the BackgroundService. // Register receiver to finish activity from the BackgroundService.
registerReceiver(mBroadcastReceiver, new IntentFilter(STOP_APP_ACTION)); ContextCompat.registerReceiver(this, mBroadcastReceiver, new IntentFilter(STOP_APP_ACTION), ContextCompat.RECEIVER_NOT_EXPORTED);
// Register file pick result launcher. // Register associated file opening result.
mFilePickResultLauncher = registerForActivityResult( mOpenFilePermissionsResult = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (Build.VERSION.SDK_INT >= 30) {
if (Environment.isExternalStorageManager()) {
onFile();
}
} else if (result.getResultCode() == RESULT_OK) {
onFile();
}
}
);
// Register file pick result.
mFilePickResult = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), new ActivityResultContracts.StartActivityForResult(),
result -> { result -> {
int resultCode = result.getResultCode(); int resultCode = result.getResultCode();
Intent data = result.getData(); Intent data = result.getData();
if (resultCode == Activity.RESULT_OK) { if (resultCode == Activity.RESULT_OK) {
String path = ""; String path = "";
if (data != null) { if (data != null && data.getData() != null) {
Uri uri = data.getData(); Uri uri = data.getData();
String name = "pick" + Utils.getFileExtension(uri, this); String name = "pick" + Utils.getFileExtension(uri, this);
File file = new File(getExternalCacheDir(), name); File file = new File(getExternalCacheDir(), name);
@@ -107,11 +129,13 @@ public class MainActivity extends GameActivity {
OutputStream os = new FileOutputStream(file)) { OutputStream os = new FileOutputStream(file)) {
byte[] buffer = new byte[1024]; byte[] buffer = new byte[1024];
int length; int length;
while ((length = is.read(buffer)) > 0) { while (true) {
assert is != null;
if (!((length = is.read(buffer)) > 0)) break;
os.write(buffer, 0, length); os.write(buffer, 0, length);
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); Log.e("grim", e.toString());
} }
path = file.getPath(); path = file.getPath();
} }
@@ -124,7 +148,7 @@ public class MainActivity extends GameActivity {
// Listener for display insets (cutouts) to pass values into native code. // Listener for display insets (cutouts) to pass values into native code.
View content = getWindow().getDecorView().findViewById(android.R.id.content); View content = getWindow().getDecorView().findViewById(android.R.id.content);
ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> { ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> {
// Setup cutouts values. // Get display cutouts.
DisplayCutoutCompat dc = insets.getDisplayCutout(); DisplayCutoutCompat dc = insets.getDisplayCutout();
int cutoutTop = 0; int cutoutTop = 0;
int cutoutRight = 0; int cutoutRight = 0;
@@ -140,7 +164,7 @@ public class MainActivity extends GameActivity {
// Get display insets. // Get display insets.
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
// Setup values to pass into native code. // Pass values into native code.
int[] values = new int[]{0, 0, 0, 0}; int[] values = new int[]{0, 0, 0, 0};
values[0] = Utils.pxToDp(Integer.max(cutoutTop, systemBars.top), this); values[0] = Utils.pxToDp(Integer.max(cutoutTop, systemBars.top), this);
values[1] = Utils.pxToDp(Integer.max(cutoutRight, systemBars.right), this); values[1] = Utils.pxToDp(Integer.max(cutoutRight, systemBars.right), this);
@@ -166,8 +190,65 @@ public class MainActivity extends GameActivity {
BackgroundService.start(this); BackgroundService.start(this);
} }
}); });
// Check if intent has data on launch.
if (savedInstanceState == null) {
onNewIntent(getIntent());
}
} }
// Pass display insets into native code.
public native void onDisplayInsets(int[] cutouts);
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
String action = intent.getAction();
// Check if file was open with the application.
if (action != null && action.equals(Intent.ACTION_VIEW)) {
Intent i = getIntent();
i.setData(intent.getData());
setIntent(i);
onFile();
}
}
// Callback when associated file was open.
private void onFile() {
Uri data = getIntent().getData();
if (data == null) {
return;
}
if (Build.VERSION.SDK_INT >= 30) {
if (!Environment.isExternalStorageManager()) {
Intent i = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
mOpenFilePermissionsResult.launch(i);
return;
}
}
try {
ParcelFileDescriptor parcelFile = getContentResolver().openFileDescriptor(data, "r");
assert parcelFile != null;
FileReader fileReader = new FileReader(parcelFile.getFileDescriptor());
BufferedReader reader = new BufferedReader(fileReader);
String line;
StringBuilder buff = new StringBuilder();
while ((line = reader.readLine()) != null) {
buff.append(line);
}
reader.close();
fileReader.close();
// Provide file content into native code.
onData(buff.toString());
} catch (Exception e) {
Log.e("grim", e.toString());
}
}
// Pass data into native code.
public native void onData(String data);
@Override @Override
public void onConfigurationChanged(Configuration newConfig) { public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig); super.onConfigurationChanged(newConfig);
@@ -184,65 +265,110 @@ public class MainActivity extends GameActivity {
if (results.length != 0 && results[0] == PackageManager.PERMISSION_GRANTED) { if (results.length != 0 && results[0] == PackageManager.PERMISSION_GRANTED) {
switch (requestCode) { switch (requestCode) {
case NOTIFICATIONS_PERMISSION_CODE: { case NOTIFICATIONS_PERMISSION_CODE: {
// Start notification service.
BackgroundService.start(this); BackgroundService.start(this);
return; return;
} }
case CAMERA_PERMISSION_CODE: { case CAMERA_PERMISSION_CODE: {
// Start camera.
startCamera(); startCamera();
} }
} }
} }
} }
@Override
protected void onTextInputEventNative(long l, State state) {
super.onTextInputEventNative(l, state);
if (state.selectionEnd > state.composingRegionStart && state.composingRegionStart >= 0) {
String input = String.valueOf(state.text.charAt(state.composingRegionStart));
if (input.contains("\n")) {
onEnterInput();
} else {
onTextInput(input);
}
}
}
@Override @Override
public boolean dispatchKeyEvent(KeyEvent event) { public boolean dispatchKeyEvent(KeyEvent event) {
// To support non-english input. if (event.getAction() == KeyEvent.ACTION_DOWN) {
if (event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) { if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
if (!event.getCharacters().isEmpty()) { onBack();
onInput(event.getCharacters()); return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
onClearInput();
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) {
onEnterInput();
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_0) {
onTextInput("0");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_1) {
onTextInput("1");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_2) {
onTextInput("2");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_3) {
onTextInput("3");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_4) {
onTextInput("4");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_5) {
onTextInput("5");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_6) {
onTextInput("6");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_7) {
onTextInput("7");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_8) {
onTextInput("8");
return false;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_9) {
onTextInput("9");
return false; return false;
} }
// Pass any other input values into native code. } else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
if (!event.getCharacters().isEmpty()) {
onTextInput(event.getCharacters());
return false;
}
// Pass any other input values into native code.
} else if (event.getAction() == KeyEvent.ACTION_UP && } else if (event.getAction() == KeyEvent.ACTION_UP &&
event.getKeyCode() != KeyEvent.KEYCODE_ENTER && event.getKeyCode() != KeyEvent.KEYCODE_ENTER &&
event.getKeyCode() != KeyEvent.KEYCODE_BACK) { event.getKeyCode() != KeyEvent.KEYCODE_BACK) {
onInput(String.valueOf((char)event.getUnicodeChar())); onTextInput(String.valueOf((char)event.getUnicodeChar()));
return false; return false;
} }
return super.dispatchKeyEvent(event); return super.dispatchKeyEvent(event);
} }
// Provide last entered character from soft keyboard into native code. // Pass back navigation event into native code.
public native void onInput(String character);
// Implemented into native code to handle display insets change.
native void onDisplayInsets(int[] cutouts);
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
onBack();
return true;
}
return super.onKeyDown(keyCode, event);
}
// Implemented into native code to handle key code BACK event.
public native void onBack(); public native void onBack();
// Actions on app exit. // Pass clear key event into native code.
private void onExit() { public native void onClearInput();
unregisterReceiver(mBroadcastReceiver);
BackgroundService.stop(this); // Pass enter key event into native code.
public native void onEnterInput();
// Pass last entered character from soft keyboard into native code.
public native void onTextInput(String character);
// Called from native code to exit app.
public void exit() {
finishAndRemoveTask();
} }
@Override @Override
protected void onDestroy() { protected void onDestroy() {
onExit(); unregisterReceiver(mBroadcastReceiver);
BackgroundService.stop(this);
// Kill process after 3 seconds if app was terminated from recent apps to prevent app hanging. // Kill process after 3 secs if app was terminated from recent apps to prevent app hang.
new Thread(() -> { new Thread(() -> {
try { try {
onTermination(); onTermination();
@@ -253,9 +379,7 @@ public class MainActivity extends GameActivity {
} }
}).start(); }).start();
// Destroy an app and kill process.
super.onDestroy(); super.onDestroy();
Process.killProcess(Process.myPid());
} }
// Notify native code to stop activity (e.g. node) if app was terminated from recent apps. // Notify native code to stop activity (e.g. node) if app was terminated from recent apps.
@@ -271,45 +395,33 @@ public class MainActivity extends GameActivity {
// Called from native code to get text from clipboard. // Called from native code to get text from clipboard.
public String pasteText() { public String pasteText() {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
String text; ClipDescription desc = clipboard.getPrimaryClipDescription();
ClipData data = clipboard.getPrimaryClip();
String text = "";
if (!(clipboard.hasPrimaryClip())) { if (!(clipboard.hasPrimaryClip())) {
text = ""; text = "";
} else if (!(clipboard.getPrimaryClipDescription().hasMimeType(MIMETYPE_TEXT_PLAIN)) } else if (desc != null && (!(desc.hasMimeType(MIMETYPE_TEXT_PLAIN))
&& !(clipboard.getPrimaryClipDescription().hasMimeType(MIMETYPE_TEXT_HTML))) { && !(desc.hasMimeType(MIMETYPE_TEXT_HTML)))) {
text = ""; text = "";
} else { } else if (data != null) {
ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0); ClipData.Item item = data.getItemAt(0);
text = item.getText().toString(); text = item.getText().toString();
} }
return text; return text;
} }
// Called from native code to show keyboard.
public void showKeyboard() {
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(getWindow().getDecorView(), InputMethodManager.SHOW_IMPLICIT);
}
// Called from native code to hide keyboard.
public void hideKeyboard() {
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(getWindow().getDecorView().getWindowToken(), 0);
}
// Called from native code to start camera. // Called from native code to start camera.
public void startCamera() { public void startCamera() {
// Check permissions.
String notificationsPermission = Manifest.permission.CAMERA; String notificationsPermission = Manifest.permission.CAMERA;
if (checkSelfPermission(notificationsPermission) != PackageManager.PERMISSION_GRANTED) { if (checkSelfPermission(notificationsPermission) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[] { notificationsPermission }, CAMERA_PERMISSION_CODE); requestPermissions(new String[] { notificationsPermission }, CAMERA_PERMISSION_CODE);
} else { } else {
// Start .
if (mCameraProviderFuture == null) { if (mCameraProviderFuture == null) {
mCameraProviderFuture = ProcessCameraProvider.getInstance(this); mCameraProviderFuture = ProcessCameraProvider.getInstance(this);
mCameraProviderFuture.addListener(() -> { mCameraProviderFuture.addListener(() -> {
try { try {
mCameraProvider = mCameraProviderFuture.get(); mCameraProvider = mCameraProviderFuture.get();
// Launch camera. // Start camera.
openCamera(); openCamera();
} catch (Exception e) { } catch (Exception e) {
View content = findViewById(android.R.id.content); View content = findViewById(android.R.id.content);
@@ -381,14 +493,14 @@ public class MainActivity extends GameActivity {
// Pass image from camera into native code. // Pass image from camera into native code.
public native void onCameraImage(byte[] buff, int rotation); public native void onCameraImage(byte[] buff, int rotation);
// Called from native code to share image from provided path. // Called from native code to share data from provided path.
public void shareImage(String path) { public void shareData(String path) {
File file = new File(path); File file = new File(path);
Uri uri = FileProvider.getUriForFile(this, "mw.gri.android.fileprovider", file); Uri uri = FileProvider.getUriForFile(this, "mw.gri.android.fileprovider", file);
Intent intent = new Intent(Intent.ACTION_SEND); Intent intent = new Intent(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_STREAM, uri); intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.setType("image/*"); intent.setType("text/*");
startActivity(Intent.createChooser(intent, "Share image")); startActivity(Intent.createChooser(intent, "Share data"));
} }
// Called from native code to check if device is using dark theme. // Called from native code to check if device is using dark theme.
@@ -400,9 +512,9 @@ public class MainActivity extends GameActivity {
// Called from native code to pick the file. // Called from native code to pick the file.
public void pickFile() { public void pickFile() {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT); Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*"); intent.setType("text/*");
try { try {
mFilePickResultLauncher.launch(Intent.createChooser(intent, "Pick file")); mFilePickResult.launch(Intent.createChooser(intent, "Pick file"));
} catch (android.content.ActivityNotFoundException ex) { } catch (android.content.ActivityNotFoundException ex) {
onFilePick(""); onFilePick("");
} }
@@ -4,23 +4,18 @@ import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import java.util.Objects;
public class NotificationActionsReceiver extends BroadcastReceiver { public class NotificationActionsReceiver extends BroadcastReceiver {
@Override @Override
public void onReceive(Context context, Intent i) { public void onReceive(Context context, Intent i) {
String a = i.getAction(); String a = i.getAction();
if (a.equals(BackgroundService.ACTION_START_NODE)) { if (Objects.equals(a, BackgroundService.ACTION_START_NODE)) {
startNode(); startNode();
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH)); } else if (Objects.equals(a, BackgroundService.ACTION_STOP_NODE)) {
} else if (a.equals(BackgroundService.ACTION_STOP_NODE)) {
stopNode(); stopNode();
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH));
} else { } else {
if (isNodeRunning()) { stopNodeToExit();
stopNodeToExit();
context.sendBroadcast(new Intent(BackgroundService.ACTION_REFRESH));
} else {
context.sendBroadcast(new Intent(MainActivity.STOP_APP_ACTION));
}
} }
} }
@@ -30,6 +25,4 @@ public class NotificationActionsReceiver extends BroadcastReceiver {
native void stopNode(); native void stopNode();
// Stop node and exit from the app. // Stop node and exit from the app.
native void stopNodeToExit(); native void stopNodeToExit();
// Check if node is running.
native boolean isNodeRunning();
} }
@@ -153,4 +153,4 @@ public class Utils {
String fileType = context.getContentResolver().getType(uri); String fileType = context.getContentResolver().getType(uri);
return MimeTypeMap.getSingleton().getExtensionFromMimeType(fileType); return MimeTypeMap.getSingleton().getExtensionFromMimeType(fileType);
} }
} }
@@ -3,6 +3,7 @@
<item name="android:statusBarColor">@color/yellow</item> <item name="android:statusBarColor">@color/yellow</item>
<item name="android:windowLightStatusBar">true</item> <item name="android:windowLightStatusBar">true</item>
<item name="android:navigationBarColor">@color/black</item> <item name="android:navigationBarColor">@color/black</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="o_mr1">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="o_mr1">shortEdges</item>
</style> </style>
</resources> </resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<external-cache-path name="images" path="images/" /> <external-cache-path name="share" path="share/" />
</paths> </paths>
+3 -8
View File
@@ -1,10 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '8.1.1' apply false id 'com.android.application' version '8.10.0' apply false
id 'com.android.library' version '8.1.1' apply false id 'com.android.library' version '8.10.0' apply false
} }
task clean(type: Delete) {
delete rootProject.buildDir
}
+1 -2
View File
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html # http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
@@ -19,5 +19,4 @@ android.useAndroidX=true
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false android.nonFinalResIds=false
+1 -1
View File
@@ -1,6 +1,6 @@
#Mon May 02 15:39:12 BST 2022 #Mon May 02 15:39:12 BST 2022
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionUrl=https\://code.gri.mw/DEV/gradle/releases/download/v8.11.1/gradle-8.11.1-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+25 -12
View File
@@ -1,16 +1,29 @@
pluginManagement { pluginManagement {
repositories { repositories {
gradlePluginPortal() maven {
google() credentials {
mavenCentral() username "$mavenUser"
password "$mavenPassword"
}
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
}
} }
} }
dependencyResolutionManagement { include ':app'
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
//rootProject.name = "Rust Template"
include ':app'
+101
View File
@@ -0,0 +1,101 @@
use std::path::PathBuf;
use std::process::Command;
use std::{env, fs};
fn main() {
built::write_built_file().expect("Failed to acquire build-time information");
// Setting up git hooks in the project: rustfmt and so on.
let git_hooks = format!(
"git config core.hooksPath {}",
PathBuf::from("./.hooks").to_str().unwrap()
);
if cfg!(target_os = "windows") {
Command::new("cmd")
.args(&["/C", &git_hooks])
.output()
.expect("failed to execute git config for hooks");
} else {
Command::new("sh")
.args(&["-c", &git_hooks])
.output()
.expect("failed to execute git config for hooks");
}
let out_dir = env::var("OUT_DIR").unwrap();
let tor_out_dir = format!("{}/tor", out_dir);
let mut webtunnel_file = format!("{}/webtunnel", tor_out_dir);
let exists = fs::exists(&webtunnel_file).unwrap();
if !exists {
// Create empty webtunnel file to allow build with include_bytes! macro.
fs::create_dir(&tor_out_dir).unwrap_or_default();
fs::File::create(&webtunnel_file).unwrap();
}
let target = env::var("TARGET").unwrap();
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
if target_os == "ios" {
return;
}
let is_android = target_os == "android";
if is_android {
// Set a path to Android Webtunnel binary.
let arch = if target.contains("aarch64") {
"arm64-v8a"
} else if target.contains("arm") {
"armeabi-v7a"
} else {
"x86_64"
};
let root = env::var("CARGO_MANIFEST_DIR").unwrap();
webtunnel_file = format!(
"{}/android/app/src/main/jniLibs/{}/libwebtunnel.so",
root, arch
);
}
// Build if Webtunnel binary is empty or not exists.
let empty = match fs::File::open(&webtunnel_file) {
Ok(file) => file.metadata().unwrap().len() == 0,
Err(_) => true,
};
let build = !exists || empty;
if build {
// Setup GOOS env variable.
let go_os = if target_os == "macos" {
"darwin"
} else {
target_os.as_str()
};
// Setup GOARCH env variable.
let go_arch = if target.contains("aarch64") {
"arm64"
} else if target.contains("arm") {
"arm"
} else {
"amd64"
};
// Run Webtunnel Go build.
let output = if env::consts::OS == "windows" {
Command::new("./scripts/webtunnel.bat")
.arg(go_os)
.arg(go_arch)
.arg(webtunnel_file)
.output()
} else {
Command::new("bash")
.arg("./scripts/webtunnel.sh")
.arg(go_os)
.arg(go_arch)
.arg(webtunnel_file)
.output()
};
if let Ok(out) = output {
if out.status.code().is_none() || out.status.code().unwrap() != 0 {
panic!("webtunnel go build failed:\n{:?}", out);
}
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+2 -1
View File
@@ -3,4 +3,5 @@ Name=Grim
Exec=grim Exec=grim
Icon=grim Icon=grim
Type=Application Type=Application
Categories=Finance Categories=Finance
MimeType=application/x-slatepack;text/plain;
+3 -3
View File
@@ -4,7 +4,7 @@ case $2 in
x86_64|arm) x86_64|arm)
;; ;;
*) *)
echo "Usage: release_linux.sh [version] [platform]\n - platform: 'x86_64', 'arm'" >&2 echo "Usage: release_linux.sh [platform] [version]\n - platform: 'x86_64', 'arm'" >&2
exit 1 exit 1
esac esac
@@ -17,11 +17,11 @@ cd ..
[[ $2 == "x86_64" ]] && arch+=(x86_64-unknown-linux-gnu) [[ $2 == "x86_64" ]] && arch+=(x86_64-unknown-linux-gnu)
[[ $2 == "arm" ]] && arch+=(aarch64-unknown-linux-gnu) [[ $2 == "arm" ]] && arch+=(aarch64-unknown-linux-gnu)
# Start release build with zig linker for cross-compilation rustup target add ${arch}
cargo install cargo-zigbuild cargo install cargo-zigbuild
cargo zigbuild --release --target ${arch} cargo zigbuild --release --target ${arch}
# Create AppImage with https://github.com/AppImage/appimagetool # Create AppImage with https://github.com/AppImage/appimagetool
cp target/${arch}/release/grim linux/Grim.AppDir/AppRun cp target/${arch}/release/grim linux/Grim.AppDir/AppRun
rm target/${arch}/release/*.AppImage rm target/${arch}/release/*.AppImage
appimagetool linux/Grim.AppDir target/${arch}/release/grim-v$1-linux-$2.AppImage appimagetool linux/Grim.AppDir target/${arch}/release/grim-v$2-linux-$1.AppImage
+71 -5
View File
@@ -25,9 +25,20 @@ share: teilen
theme: 'Theme:' theme: 'Theme:'
dark: Dunkel dark: Dunkel
light: Hell light: Hell
file: Datei
choose_file: Datei auswählen choose_file: Datei auswählen
choose_folder: Ordner auswählen
crash_report: Absturzbericht crash_report: Absturzbericht
crash_report_warning: Anwendung wurde beim letzten Mal unerwartet geschlossen, Sie können den Absturzbericht mit Entwicklern teilen. crash_report_warning: Anwendung wurde beim letzten Mal unerwartet geschlossen, Sie können den Absturzbericht mit Entwicklern teilen.
confirmation: Bestätigung
enter_url: URL eingeben
max_short: MAX
files_location: Dateistandort
moving_files: Dateien verschieben
wrong_path_error: Falscher Weg angegeben
check_updates: Suchen Sie beim Start nach Updates
update_available: Update ist verfügbar!
changelog: 'Wechselbuch:'
wallets: wallets:
await_conf_amount: Erwarte Bestätigung await_conf_amount: Erwarte Bestätigung
await_fin_amount: Warten auf die Fertigstellung await_fin_amount: Warten auf die Fertigstellung
@@ -82,6 +93,7 @@ wallets:
tx_canceled: Abgebrochen tx_canceled: Abgebrochen
tx_cancelling: Abbrechen tx_cancelling: Abbrechen
tx_finalizing: Finalisierung tx_finalizing: Finalisierung
tx_posting: Buchungsvorgang
tx_confirmed: Bestätigt tx_confirmed: Bestätigt
txs: Transaktionen txs: Transaktionen
tx: Transaktion tx: Transaktion
@@ -125,6 +137,12 @@ wallets:
tx_receive_cancel_conf: 'Sind Sie sicher, dass Sie das Empfangen von %{amount} ツ abbrechen wollen?' tx_receive_cancel_conf: 'Sind Sie sicher, dass Sie das Empfangen von %{amount} ツ abbrechen wollen?'
rec_phrase_not_found: Wiederhestellungsphrase nicht gefunden. rec_phrase_not_found: Wiederhestellungsphrase nicht gefunden.
restore_wallet_desc: Stellen Sie das Wallet wieder her, indem Sie alle Dateien löschen. Wenn die normale Reparatur nicht geholfen hat, müssen Sie Ihr Wallet erneut öffnen. restore_wallet_desc: Stellen Sie das Wallet wieder her, indem Sie alle Dateien löschen. Wenn die normale Reparatur nicht geholfen hat, müssen Sie Ihr Wallet erneut öffnen.
fee_base_desc: 'Gebühr (basiswert%{value}):'
payment_proof: Zahlungsnachweis
payment_proof_desc: 'Geben Sie den erhaltenen Zahlungsnachweis ein, um die Transaktion zu verifizieren:'
payment_proof_valid: 'Der eingegebene Zahlungsnachweis ist gültig:'
payment_proof_error: 'Der eingetragene Zahlungsnachweis ist nicht gültig:'
tx_delete_confirmation: Bist du sicher, dass du die Transaktion aus dem Verlauf löschen möchtest?
transport: transport:
desc: 'Transport verwenden, um Nachrichten synchron zu empfangen oder zu senden:' desc: 'Transport verwenden, um Nachrichten synchron zu empfangen oder zu senden:'
tor_network: Tor Netzwek tor_network: Tor Netzwek
@@ -134,10 +152,11 @@ transport:
conn_error: Verbindungsproblem conn_error: Verbindungsproblem
disconnected: Verbindung getrennt disconnected: Verbindung getrennt
receiver_address: 'Empfängeraddresse:' receiver_address: 'Empfängeraddresse:'
sender_address: 'Absenderadresse:'
incorrect_addr_err: 'Eingegebene Addresse ist inkorrekt:' incorrect_addr_err: 'Eingegebene Addresse ist inkorrekt:'
tor_send_error: Beim Senden über Tor ist ein Fehler aufgetreten. Stellen Sie sicher, dass der Empfänger online ist. Die Transaktion wurde abgebrochen. tor_send_error: Beim Senden über Tor ist ein Fehler aufgetreten. Stellen Sie sicher, dass der Empfänger online ist. Die Transaktion wurde abgebrochen.
tor_autorun_desc: Gibt an, ob beim Öffnen des Wallets der Tor-Dienst gestartet werden soll, um Transaktionen synchron zu empfangen. tor_autorun_desc: Gibt an, ob beim Öffnen des Wallets der Tor-Dienst gestartet werden soll, um Transaktionen synchron zu empfangen.
tor_sending: 'Sende %{amount} ツ über Tor' tor_sending: Sende über Tor
tor_settings: Tor Einstellungen tor_settings: Tor Einstellungen
bridges: Brücken bridges: Brücken
bridges_desc: Richten Sie Brücken ein, um die Zensur des Tor-Netzwerks zu umgehen, wenn die normale Verbindung nicht funktioniert. bridges_desc: Richten Sie Brücken ein, um die Zensur des Tor-Netzwerks zu umgehen, wenn die normale Verbindung nicht funktioniert.
@@ -282,13 +301,60 @@ network_settings:
ban_window_desc: Die Entscheidung über das Verbot trifft der Knoten auf der Grundlage der Korrektheit der von der Gegenstelle erhaltenen Daten. 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_inbound_count: 'Maximale Anzahl der eingehenden Peer-Verbindungen:'
max_outbound_count: 'Maximale Anzahl von ausgehenden 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_data_desc: Reset-Knotendaten. Verwenden Sie diese Funktion nur, wenn es Probleme mit der Synchronisation gibt.
reset_peers: Peers zurücksetzten reset_data: Daten zurücksetzten
ip_listen_all: Hören Sie auf allen Schnittstellen
modal: modal:
cancel: Abbrechen cancel: Abbrechen
save: Speichern save: Speichern
confirmation: Bestätigung
add: Hinzufügen add: Hinzufügen
modal_exit: modal_exit:
description: Sind Sie sicher, dass Sie die Anwendung beenden wollen? description: Sind Sie sicher, dass Sie die Anwendung beenden wollen?
exit: Schließen exit: Schließen
app_settings:
proxy: Proxy
proxy_desc: Lohnt es sich, einen Proxy für Netzwerkanfragen von der Anwendung zu verwenden.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: ß
q: q
w: w
e: e
r: r
t: t
y: z
u: u
i: i
o: o
p: p
p1: ü
a: a
s: s
d: d
f: f
g: g
h: h
j: j
k: k
l: l
l1: ö
l2: ä
z: y
x: x
c: c
v: v
b: b
n: n
m: m
m1: ','
m2: .
m3: '/'
+71 -5
View File
@@ -25,9 +25,20 @@ share: Share
theme: 'Theme:' theme: 'Theme:'
dark: Dark dark: Dark
light: Light light: Light
file: File
choose_file: Choose file choose_file: Choose file
choose_folder: Choose folder
crash_report: Crash report crash_report: Crash report
crash_report_warning: Application closed unexpectedly last time, you can share crash report with developers. crash_report_warning: Application closed unexpectedly last time, you can share crash report with developers.
confirmation: Confirmation
enter_url: Enter URL
max_short: MAX
files_location: Files location
moving_files: Moving files
wrong_path_error: Wrong path specified
check_updates: Check for updates at startup
update_available: Update is available!
changelog: 'Changelog:'
wallets: wallets:
await_conf_amount: Awaiting confirmation await_conf_amount: Awaiting confirmation
await_fin_amount: Awaiting finalization await_fin_amount: Awaiting finalization
@@ -82,6 +93,7 @@ wallets:
tx_canceled: Canceled tx_canceled: Canceled
tx_cancelling: Cancelling tx_cancelling: Cancelling
tx_finalizing: Finalizing tx_finalizing: Finalizing
tx_posting: Posting
tx_confirmed: Confirmed tx_confirmed: Confirmed
txs: Transactions txs: Transactions
tx: Transaction tx: Transaction
@@ -125,6 +137,12 @@ wallets:
tx_receive_cancel_conf: 'Are you sure you want to cancel receiving of %{amount} ツ?' tx_receive_cancel_conf: 'Are you sure you want to cancel receiving of %{amount} ツ?'
rec_phrase_not_found: Recovery phrase not found. rec_phrase_not_found: Recovery phrase not found.
restore_wallet_desc: Restore wallet by deleting all files if usual repair not helped, you will need to re-open your wallet. restore_wallet_desc: Restore wallet by deleting all files if usual repair not helped, you will need to re-open your wallet.
fee_base_desc: 'Fee (base value%{value}):'
payment_proof: Payment proof
payment_proof_desc: 'Enter received payment proof to verify transaction:'
payment_proof_valid: 'Entered payment proof is valid:'
payment_proof_error: 'Entered payment proof is not valid:'
tx_delete_confirmation: Are you sure you want to delete the transaction from history?
transport: transport:
desc: 'Use transport to receive or send messages synchronously:' desc: 'Use transport to receive or send messages synchronously:'
tor_network: Tor network tor_network: Tor network
@@ -134,10 +152,11 @@ transport:
conn_error: Connection error conn_error: Connection error
disconnected: Disconnected disconnected: Disconnected
receiver_address: 'Address of the receiver:' receiver_address: 'Address of the receiver:'
sender_address: 'Address of the sender:'
incorrect_addr_err: 'Entered address is incorrect:' incorrect_addr_err: 'Entered address is incorrect:'
tor_send_error: An error occurred during sending over Tor, make sure receiver is online, transaction was canceled. tor_send_error: An error occurred during sending over Tor, make sure receiver is online, transaction was canceled.
tor_autorun_desc: Whether to launch Tor service on wallet opening to receive transactions synchronously. tor_autorun_desc: Whether to launch Tor service on wallet opening to receive transactions synchronously.
tor_sending: 'Sending %{amount} ツ over Tor' tor_sending: Sending over Tor
tor_settings: Tor Settings tor_settings: Tor Settings
bridges: Bridges bridges: Bridges
bridges_desc: Setup bridges to bypass Tor network censorship if usual connection is not working. bridges_desc: Setup bridges to bypass Tor network censorship if usual connection is not working.
@@ -282,13 +301,60 @@ network_settings:
ban_window_desc: The decision to ban is made by node, based on the correctness of the data received from the peer. 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_inbound_count: 'Maximum number of inbound peer connections:'
max_outbound_count: 'Maximum number of outbound 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_data_desc: Reset the node data. Use it with a caution only if there are problems with synchronization.
reset_peers: Reset peers reset_data: Reset data
ip_listen_all: Listen on all interfaces
modal: modal:
cancel: Cancel cancel: Cancel
save: Save save: Save
confirmation: Confirmation
add: Add add: Add
modal_exit: modal_exit:
description: Are you sure you want to quit the application? description: Are you sure you want to quit the application?
exit: Exit exit: Exit
app_settings:
proxy: Proxy
proxy_desc: Whether to use proxy for network requests from the application.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '-'
q: q
w: w
e: e
r: r
t: t
y: y
u: u
i: i
o: o
p: p
p1: '"'
a: a
s: s
d: d
f: f
g: g
h: h
j: j
k: k
l: l
l1: \
l2: ':'
z: z
x: x
c: c
v: v
b: b
n: n
m: m
m1: ','
m2: .
m3: /
+71 -5
View File
@@ -25,9 +25,20 @@ share: Partager
theme: 'Thème:' theme: 'Thème:'
dark: Sombre dark: Sombre
light: Clair light: Clair
file: Fichier
choose_file: Choisir un fichier choose_file: Choisir un fichier
choose_folder: Choisir un dossier
crash_report: Rapport d'échec crash_report: Rapport d'échec
crash_report_warning: L'application s'est fermée de manière inattendue la dernière fois, vous pouvez partager un rapport d'incident avec les développeurs. crash_report_warning: L'application s'est fermée de manière inattendue la dernière fois, vous pouvez partager un rapport d'incident avec les développeurs.
confirmation: Confirmation
enter_url: Entrez l'URL
max_short: MAX
files_location: Emplacement du fichier
moving_files: Déplacer des fichiers
wrong_path_error: Chemin incorrect spécifié
check_updates: Vérifiez les mises à jour au démarrage
update_available: Mise à jour disponible!
changelog: 'Journal des modifications:'
wallets: wallets:
await_conf_amount: En attente de confirmation await_conf_amount: En attente de confirmation
await_fin_amount: En attente de finalisation await_fin_amount: En attente de finalisation
@@ -82,6 +93,7 @@ wallets:
tx_canceled: Annulé tx_canceled: Annulé
tx_cancelling: Annulation tx_cancelling: Annulation
tx_finalizing: Finalisation tx_finalizing: Finalisation
tx_posting: Publication
tx_confirmed: Confirmé tx_confirmed: Confirmé
txs: Transactions txs: Transactions
tx: Transaction tx: Transaction
@@ -125,6 +137,12 @@ wallets:
tx_receive_cancel_conf: 'Êtes-vous sûr de vouloir annuler la réception de %{amount} ツ?' tx_receive_cancel_conf: 'Êtes-vous sûr de vouloir annuler la réception de %{amount} ツ?'
rec_phrase_not_found: Phrase de récupération non trouvée. rec_phrase_not_found: Phrase de récupération non trouvée.
restore_wallet_desc: "Restaurer le portefeuille en supprimant tous les fichiers si la réparation habituelle n'a pas aidé. Vous devrez rouvrir votre portefeuille." restore_wallet_desc: "Restaurer le portefeuille en supprimant tous les fichiers si la réparation habituelle n'a pas aidé. Vous devrez rouvrir votre portefeuille."
fee_base_desc: 'Frais (valeur de base%{value}):'
payment_proof: Preuve de paiement
payment_proof_desc: 'Saisissez la preuve de paiement reçue pour vérifier la transaction:'
payment_proof_valid: 'La preuve de paiement saisie est valide:'
payment_proof_error: "La preuve de paiement saisie n'est pas valide:"
tx_delete_confirmation: Êtes-vous sûr de vouloir supprimer la transaction de l'historique?
transport: transport:
desc: 'Utilisez le transport pour recevoir ou envoyer des messages de manière synchronisée:' desc: 'Utilisez le transport pour recevoir ou envoyer des messages de manière synchronisée:'
tor_network: Réseau Tor tor_network: Réseau Tor
@@ -134,10 +152,11 @@ transport:
conn_error: Erreur de connexion conn_error: Erreur de connexion
disconnected: Déconnecté disconnected: Déconnecté
receiver_address: 'Adresse du destinataire:' receiver_address: 'Adresse du destinataire:'
sender_address: "Adresse de l'expéditeur:"
incorrect_addr_err: 'Adresse entrée incorrecte:' incorrect_addr_err: 'Adresse entrée incorrecte:'
tor_send_error: "Une erreur s'est produite lors de l'envoi via Tor. Assurez-vous que le destinataire est en ligne, la transaction a été annulée." tor_send_error: "Une erreur s'est produite lors de l'envoi via Tor. Assurez-vous que le destinataire est en ligne, la transaction a été annulée."
tor_autorun_desc: "Lancer automatiquement le service Tor à l'ouverture du portefeuille pour recevoir les transactions de manière synchronisée." tor_autorun_desc: "Lancer automatiquement le service Tor à l'ouverture du portefeuille pour recevoir les transactions de manière synchronisée."
tor_sending: 'Envoi de %{amount} ツ via Tor' tor_sending: Envoi via Tor
tor_settings: Paramètres Tor tor_settings: Paramètres Tor
bridges: Passerelles bridges: Passerelles
bridges_desc: Configurez des passerelles pour contourner la censure du réseau Tor si la connexion habituelle ne fonctionne pas. bridges_desc: Configurez des passerelles pour contourner la censure du réseau Tor si la connexion habituelle ne fonctionne pas.
@@ -282,13 +301,60 @@ 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. 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_inbound_count: 'Nombre maximum de connexions de pairs entrants :'
max_outbound_count: 'Nombre maximum de connexions de pairs sortants :' 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_data_desc: Réinitialisez les données du noeud. Utilisez-le avec prudence uniquement en cas de problème de synchronisation.
reset_peers: Réinitialiser les pairs reset_data: Réinitialisation des données
ip_listen_all: Écoutez sur toutes les interfaces
modal: modal:
cancel: Annuler cancel: Annuler
save: Sauvegarder save: Sauvegarder
confirmation: Confirmation
add: Ajouter add: Ajouter
modal_exit: modal_exit:
description: "Êtes-vous sûr de vouloir quitter l'application ?" description: "Êtes-vous sûr de vouloir quitter l'application ?"
exit: Quitter exit: Quitter
app_settings:
proxy: Proxy
proxy_desc: Vaut-il la peine d'utiliser un proxy pour les requêtes réseau de l'application.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '`'
q: a
w: z
e: e
r: r
t: t
y: y
u: u
i: i
o: o
p: p
p1: ç
a: q
s: s
d: d
f: f
g: g
h: h
j: j
k: k
l: l
l1: m
l2: ù
z: w
x: x
c: c
v: v
b: b
n: n
m: ','
m1: .
m2: ':'
m3: /
+71 -5
View File
@@ -25,9 +25,20 @@ share: Поделиться
theme: 'Тема:' theme: 'Тема:'
dark: Тёмная dark: Тёмная
light: Светлая light: Светлая
file: Файл
choose_file: Выбрать файл choose_file: Выбрать файл
choose_folder: Выбрать папку
crash_report: Отчёт о сбое crash_report: Отчёт о сбое
crash_report_warning: В прошлый раз приложение неожиданно закрылось, вы можете поделиться отчетом о сбое с разработчиками. crash_report_warning: В прошлый раз приложение неожиданно закрылось, вы можете поделиться отчетом о сбое с разработчиками.
confirmation: Подтверждение
enter_url: Введите URL-адрес
max_short: МАКС
files_location: Расположение файлов
moving_files: Перемещение файлов
wrong_path_error: Указан неправильный путь
check_updates: Проверять обновления при запуске
update_available: Доступно обновление!
changelog: 'Журнал изменений:'
wallets: wallets:
await_conf_amount: Ожидает подтверждения await_conf_amount: Ожидает подтверждения
await_fin_amount: Ожидает завершения await_fin_amount: Ожидает завершения
@@ -82,6 +93,7 @@ wallets:
tx_canceled: Отменено tx_canceled: Отменено
tx_cancelling: Отмена tx_cancelling: Отмена
tx_finalizing: Завершение tx_finalizing: Завершение
tx_posting: Публикация
tx_confirmed: Подтверждено tx_confirmed: Подтверждено
txs: Транзакции txs: Транзакции
tx: Транзакция tx: Транзакция
@@ -125,6 +137,12 @@ wallets:
tx_receive_cancel_conf: 'Вы действительно хотите отменить получение %{amount} ツ?' tx_receive_cancel_conf: 'Вы действительно хотите отменить получение %{amount} ツ?'
rec_phrase_not_found: Фраза восстановления не найдена. rec_phrase_not_found: Фраза восстановления не найдена.
restore_wallet_desc: Восстановить кошелёк, удалив все файлы, если обычное исправление не помогло. Необходимо переоткрыть кошелёк. restore_wallet_desc: Восстановить кошелёк, удалив все файлы, если обычное исправление не помогло. Необходимо переоткрыть кошелёк.
fee_base_desc: 'Комиссия (базовое значение%{value}):'
payment_proof: Подтверждение оплаты
payment_proof_desc: 'Введите полученное подтверждение оплаты для проверки транзакции:'
payment_proof_valid: 'Введённое подтверждение оплаты действительно:'
payment_proof_error: 'Введённое подтверждение оплаты недействительно:'
tx_delete_confirmation: Вы уверены, что хотите удалить транзакцию из истории?
transport: transport:
desc: 'Используйте транспорт для синхронных получения или отправки сообщений:' desc: 'Используйте транспорт для синхронных получения или отправки сообщений:'
tor_network: Сеть Tor tor_network: Сеть Tor
@@ -134,10 +152,11 @@ transport:
conn_error: Ошибка подключения conn_error: Ошибка подключения
disconnected: Отключено disconnected: Отключено
receiver_address: 'Адрес получателя:' receiver_address: 'Адрес получателя:'
sender_address: 'Адрес отправителя:'
incorrect_addr_err: 'Введённый адрес неверен:' incorrect_addr_err: 'Введённый адрес неверен:'
tor_send_error: Во время отправки через Tor произошла ошибка, убедитесь, что получатель находится онлайн, транзакция была отменена. tor_send_error: Во время отправки через Tor произошла ошибка, убедитесь, что получатель находится онлайн, транзакция была отменена.
tor_autorun_desc: Запускать ли Tor сервис при открытии кошелька для синхронного получения транзакций. tor_autorun_desc: Запускать ли Tor сервис при открытии кошелька для синхронного получения транзакций.
tor_sending: 'Отправка %{amount} ツ через Tor' tor_sending: Отправка через Tor
tor_settings: Настройки Tor tor_settings: Настройки Tor
bridges: Мосты bridges: Мосты
bridges_desc: Настройте мосты для обхода цензуры сети Tor, если обычное соединение не работает. bridges_desc: Настройте мосты для обхода цензуры сети Tor, если обычное соединение не работает.
@@ -282,13 +301,60 @@ network_settings:
ban_window_desc: Решение о запрете принимается узлом, основываясь на корректности данных полученных от пира. ban_window_desc: Решение о запрете принимается узлом, основываясь на корректности данных полученных от пира.
max_inbound_count: 'Максимальное количество входящих подключений пиров:' max_inbound_count: 'Максимальное количество входящих подключений пиров:'
max_outbound_count: 'Максимальное количество исходящих подключений к пирам:' max_outbound_count: 'Максимальное количество исходящих подключений к пирам:'
reset_peers_desc: Сбросить данные пиров. Используйте с осторожностью, только при наличии проблем с поиском пиров. reset_data_desc: Сбросить данные узла. Используйте с осторожностью, только при наличии проблем с синхронизацией.
reset_peers: Сбросить пиры reset_data: Сброс данных
ip_listen_all: Слушать на всех интерфейсах
modal: modal:
cancel: Отмена cancel: Отмена
save: Сохранить save: Сохранить
confirmation: Подтверждение
add: Добавить add: Добавить
modal_exit: modal_exit:
description: Вы уверены, что хотите выйти из приложения? description: Вы уверены, что хотите выйти из приложения?
exit: Выход exit: Выход
app_settings:
proxy: Прокси
proxy_desc: Стоит ли использовать прокси для сетевых запросов из приложения.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: ъ
q: й
w: ц
e: у
r: к
t: е
y: н
u: г
i: ш
o: щ
p: з
p1: х
a: ф
s: ы
d: в
f: а
g: п
h: р
j: о
k: л
l: д
l1: ж
l2: э
z: я
x: ч
c: с
v: м
b: и
n: т
m: ь
m1: б
m2: ю
m3: ё
+71 -5
View File
@@ -25,9 +25,20 @@ share: Paylasmak
theme: 'Tema:' theme: 'Tema:'
dark: Karanlik dark: Karanlik
light: Isik light: Isik
file: Dosya
choose_file: Dosya seçin choose_file: Dosya seçin
choose_folder: Klasör seç
crash_report: Ariza Raporu crash_report: Ariza Raporu
crash_report_warning: Uygulama beklenmedik bir sekilde kapandi son kez, kilitlenme raporunu gelistiricilerle paylasabilirsiniz. crash_report_warning: Uygulama beklenmedik bir sekilde kapandi son kez, kilitlenme raporunu gelistiricilerle paylasabilirsiniz.
confirmation: Onay
enter_url: URL'yi girin
max_short: MAKS
files_location: Dosya konumu
moving_files: Dosyalari Tasima
wrong_path_error: Yanlis yol belirtildi
check_updates: Başlangiçta güncellemeleri kontrol edin
update_available: Güncelleme mevcut!
changelog: 'Değişiklik Günlüğü:'
wallets: wallets:
await_conf_amount: Onay bekleniyor await_conf_amount: Onay bekleniyor
await_fin_amount: Tamamlanma bekleniyor await_fin_amount: Tamamlanma bekleniyor
@@ -82,6 +93,7 @@ wallets:
tx_canceled: Iptal edildi tx_canceled: Iptal edildi
tx_cancelling: Iptal ediliyor tx_cancelling: Iptal ediliyor
tx_finalizing: Islem tamamlaniyor tx_finalizing: Islem tamamlaniyor
tx_posting: Islem kaydetme
tx_confirmed: Onaylandi tx_confirmed: Onaylandi
txs: Islemler txs: Islemler
tx: Islem tx: Islem
@@ -125,6 +137,12 @@ wallets:
tx_receive_cancel_conf: Gelen tx iptal tx_receive_cancel_conf: Gelen tx iptal
rec_phrase_not_found: Sifre kelime bulunmuyor rec_phrase_not_found: Sifre kelime bulunmuyor
restore_wallet_desc: Cuzdani restore et restore_wallet_desc: Cuzdani restore et
fee_base_desc: 'Ücret (taban değeri%{value}):'
payment_proof: Ödeme kaniti
payment_proof_desc: 'Islemi doğrulamak için alinan ödeme kanitini girin:'
payment_proof_valid: 'Girilen ödeme kaniti geçerlidir:'
payment_proof_error: 'Girilen ödeme kaniti geçerli değildir:'
tx_delete_confirmation: Islemi geçmişten silmek istediğinizden emin misiniz?
transport: transport:
desc: 'Adresten senkronize GONDER veya AL:' desc: 'Adresten senkronize GONDER veya AL:'
tor_network: Tor network tor_network: Tor network
@@ -134,10 +152,11 @@ transport:
conn_error: Bagalanti hatasi conn_error: Bagalanti hatasi
disconnected: Baglanti yok disconnected: Baglanti yok
receiver_address: 'Alicinin adresi:' receiver_address: 'Alicinin adresi:'
sender_address: 'Gönderici adresi:'
incorrect_addr_err: 'Girilen adres hatali:' incorrect_addr_err: 'Girilen adres hatali:'
tor_send_error: Tor adresi uzerinden gonderimde aksaklik olustu, alici online olmasi gerek, islem iptal edildi. tor_send_error: Tor adresi uzerinden gonderimde aksaklik olustu, alici online olmasi gerek, islem iptal edildi.
tor_autorun_desc: Islemleri Tor adresi olarak AL,bunun için cuzdan acilisinda Tor hizmetinin baslatilip baslatilmayacagi. tor_autorun_desc: Islemleri Tor adresi olarak AL,bunun için cuzdan acilisinda Tor hizmetinin baslatilip baslatilmayacagi.
tor_sending: 'Tor adrese %{amount} ツ gonderiliyor.' tor_sending: Tor adrese gonderiliyor
tor_settings: Tor Ayarlar tor_settings: Tor Ayarlar
bridges: Bridges bridges: Bridges
bridges_desc: Setup bridges to bypass Tor network censorship if usual connection is not working. bridges_desc: Setup bridges to bypass Tor network censorship if usual connection is not working.
@@ -282,13 +301,60 @@ network_settings:
ban_window_desc: Banlama karari, peerden alinan verilerin dogruluguna bagli olarak Node tarafindan verilir. ban_window_desc: Banlama karari, peerden alinan verilerin dogruluguna bagli olarak Node tarafindan verilir.
max_inbound_count: 'Maksimum gelen Peer baglanti sayisi:' max_inbound_count: 'Maksimum gelen Peer baglanti sayisi:'
max_outbound_count: 'Maksimum giden 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_data_desc: Node verisini sifirlama. Sadece senkronizasyonda sorun varsa dikkatli kullanin.
reset_peers: Peers Resetle reset_data: Verileri sifirlama
ip_listen_all: Tüm arayüzlerde dinle
modal: modal:
cancel: Iptal cancel: Iptal
save: Kaydet save: Kaydet
confirmation: Onay
add: Ekle add: Ekle
modal_exit: modal_exit:
description: Uygulamadan cikmak için exit, emin misiniz? description: Uygulamadan cikmak için exit, emin misiniz?
exit: Exit exit: Exit
app_settings:
proxy: Proxy
proxy_desc: Uygulamadan gelen ağ istekleri için bir proxy kullanmaya değer mi.
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '-'
q: q
w: w
e: e
r: r
t: t
y: y
u: u
i: i
o: o
p: p
p1: ü
a: a
s: s
d: d
f: f
g: g
h: h
j: j
k: k
l: l
l1: ö
l2: ':'
z: z
x: x
c: c
v: v
b: b
n: n
m: m
m1: ','
m2: .
m3: /
+360
View File
@@ -0,0 +1,360 @@
lang_name: 简体中文
copy: 复制
paste: 粘贴
continue: 继续
complete: 完成
error: 错误
retry: 重试
close: 关闭
change: 更改
show: 显示
delete: 删除
clear: 清除
create: 创建
id: 标识符
kernel: 内核
settings: 设置
language: 语言
scan: 扫描
qr_code: 二维码
scan_qr: 扫描二维码
repeat: 重复
scan_result: 扫描结果
back: 返回
share: 分享
theme: '主题:'
dark: 深色
light: 浅色
file: 文件
choose_file: 选择文件
choose_folder: 选择文件夹
crash_report: 崩溃报告
crash_report_warning: 应用上次意外关闭,你可以将崩溃报告分享给开发者。
confirmation: 确认
enter_url: 输入网址
max_short: 最大
files_location: 文件位置
moving_files: 正在移动文件
wrong_path_error: 指定的路径错误
check_updates: 启动时检查更新
update_available: 有可用更新!
changelog: '更新日志:'
wallets:
await_conf_amount: 等待确认
await_fin_amount: 等待最终确认
locked_amount: 已锁定
txs_empty: '要手动或通过传输方式接收资金,请使用屏幕底部的 %{message} 或 %{transport} 按钮;要更改钱包设置,请按 %{settings} 按钮。'
title: 钱包
create_desc: 创建新钱包或从已保存的助记词导入现有钱包。
add: 添加钱包
name: '名称:'
pass: '密码:'
pass_empty: 输入钱包密码
current_pass: '当前密码:'
new_pass: '新密码:'
min_tx_conf_count: '交易的最少确认数:'
recover: 恢复
recovery_phrase: 助记词
words_count: '单词数量:'
enter_word: '输入第 %{number} 个单词:'
not_valid_word: 输入的单词无效
not_valid_phrase: 输入的助记词无效
create_phrase_desc: 请安全地写下并保存你的助记词。
restore_phrase_desc: 输入你保存的助记词中的单词。
setup_conn_desc: 选择钱包连接网络的方式。
conn_method: 连接方式
ext_conn: '外部连接:'
add_node: 添加节点
node_url: '节点网址:'
node_secret: 'API 密钥(可选):'
invalid_url: 输入的网址无效
open: 打开钱包
wrong_pass: 输入的密码错误
locked: 已锁定
unlocked: 已解锁
enable_node: '请启用集成节点以使用钱包,或通过选择屏幕底部的 %{settings} 更改连接设置。'
node_loading: '钱包将在集成节点同步完成后加载,你可以通过选择屏幕底部的 %{settings} 更改连接方式。'
loading: 加载中
closing: 关闭中
checking: 检查中
default_wallet: 默认钱包
new_account_desc: '输入新账户名称:'
wallet_loading: 正在加载钱包
wallet_closing: 正在关闭钱包
wallet_checking: 正在检查钱包
tx_loading: 正在加载交易
default_account: 默认账户
accounts: 账户
tx_sent: 已发送
tx_received: 已接收
tx_sending: 发送中
tx_receiving: 接收中
tx_confirming: 等待确认
tx_canceled: 已取消
tx_cancelling: 取消中
tx_finalizing: 最终确认中
tx_posting: 提交中
tx_confirmed: 已确认
txs: 交易记录
tx: 交易
messages: 消息
transport: 传输
input_slatepack_desc: '输入收到的 Slatepack 消息以创建响应或完成请求:'
parse_slatepack_err: '读取消息时发生错误,请检查输入内容:'
pay_balance_error: '账户余额不足以支付 %{amount} ツ 和网络手续费。'
parse_i1_slatepack_desc: '要支付 %{amount} ツ,请将此消息发送给接收方:'
parse_i2_slatepack_desc: '完成交易以接收 %{amount} ツ:'
parse_i3_slatepack_desc: '提交交易以完成 %{amount} ツ 的接收:'
parse_s1_slatepack_desc: '要接收 %{amount} ツ,请将此消息发送给发送方:'
parse_s2_slatepack_desc: '完成交易以发送 %{amount} ツ:'
parse_s3_slatepack_desc: '提交交易以完成 %{amount} ツ 的发送:'
resp_slatepack_err: '创建响应时发生错误,请检查输入数据或重试:'
resp_exists_err: 该交易已存在。
resp_canceled_err: 该交易已被取消。
create_request_desc: '创建发送或接收资金的请求:'
send_request_desc: '你已创建发送 %{amount} ツ 的请求。请将此消息发送给接收方:'
send_slatepack_err: 创建发送资金请求时发生错误,请检查输入数据或重试。
invoice_desc: '你已创建接收 %{amount} ツ 的请求。请将此消息发送给发送方:'
invoice_slatepack_err: 开具发票时发生错误,请检查输入数据或重试。
finalize_slatepack_err: '最终确认时发生错误,请检查输入数据或重试:'
finalize: 最终确认
use_dandelion: 使用蒲公英(Dandelion
enter_amount_send: '你有 %{amount} ツ。输入发送金额:'
enter_amount_receive: '输入接收金额:'
recovery: 恢复
repair_wallet: 修复钱包
repair_desc: 检查钱包,必要时修复和恢复丢失的输出。此操作需要一定时间。
repair_unavailable: 你需要活跃的节点连接并完成钱包同步。
delete: 删除钱包
delete_conf: 确定要删除钱包吗?
delete_desc: 请确保你已保存助记词,以便日后访问资金。
wallet_loading_err: '钱包同步时发生错误,你可以重试或通过选择屏幕底部的 %{settings} 更改连接设置。'
wallet: 钱包
send: 发送
receive: 接收
settings: 钱包设置
tx_send_cancel_conf: '确定要取消发送 %{amount} ツ 吗?'
tx_receive_cancel_conf: '确定要取消接收 %{amount} ツ 吗?'
rec_phrase_not_found: 未找到助记词。
restore_wallet_desc: 如果常规修复无效,可通过删除所有文件来恢复钱包,你需要重新打开钱包。
fee_base_desc: '手续费(基础值%{value}):'
payment_proof: 支付证明
payment_proof_desc: '输入收到的支付证明以验证交易:'
payment_proof_valid: '输入的支付证明有效:'
payment_proof_error: '输入的支付证明无效:'
tx_delete_confirmation: 确定要从历史记录中删除该交易吗?
transport:
desc: '使用传输方式同步收发消息:'
tor_network: Tor 网络
connected: 已连接
connecting: 连接中
disconnecting: 断开中
conn_error: 连接错误
disconnected: 已断开
receiver_address: '接收方地址:'
sender_address: '发送方地址:'
incorrect_addr_err: '输入的地址不正确:'
tor_send_error: 通过 Tor 发送时发生错误,请确认接收方在线,交易已取消。
tor_autorun_desc: 是否在打开钱包时启动 Tor 服务以同步接收交易。
tor_sending: 正在通过 Tor 发送
tor_settings: Tor 设置
bridges: 网桥
bridges_desc: 如果常规连接无法使用,设置网桥以绕过 Tor 网络审查。
bin_file: '二进制文件:'
conn_line: '连接线路:'
bridges_disabled: 网桥已禁用
bridge_name: '网桥 %{b}'
network:
self: 网络
type: '网络类型:'
mainnet: 主网
testnet: 测试网
connections: 连接
node: 集成节点
metrics: 指标
mining: 挖矿
settings: 节点设置
enable_node: 启用节点
autorun: 自动运行
disabled_server: '请启用集成节点,或按屏幕左上角的 %{dots} 添加其他连接方式。'
no_ips: 你的系统上没有可用的 IP 地址,无法启动服务器,请检查网络连接。
available: 可用
not_available: 不可用
availability_check: 可用性检查
android_warning: 安卓用户请注意。要成功同步集成节点,你必须在手机系统设置中允许 Grim 应用发送通知,并移除电池使用限制。这是应用在后台正常运行所必需的操作。
sync_status:
node_restarting: 节点正在重启
node_down: 节点已停止
initial: 节点正在启动
no_sync: 节点正在运行
awaiting_peers: 等待对等节点
header_sync: 正在下载区块头
header_sync_percent: '正在下载区块头:%{percent}%'
tx_hashset_pibd: 正在下载状态(PIBD
tx_hashset_pibd_percent: '正在下载状态(PIBD):%{percent}%'
tx_hashset_download: 正在下载状态
tx_hashset_download_percent: '正在下载状态:%{percent}%'
tx_hashset_setup_history: '正在准备状态(历史):%{percent}%'
tx_hashset_setup_position: '正在准备状态(位置):%{percent}%'
tx_hashset_setup: 正在准备状态
tx_hashset_range_proofs_validation: '正在验证状态(范围证明):%{percent}%'
tx_hashset_kernels_validation: '正在验证状态(内核):%{percent}%'
tx_hashset_save: 正在最终确认链状态
body_sync: 正在下载区块
body_sync_percent: '正在下载区块:%{percent}%'
shutdown: 节点正在关闭
network_node:
header: 区块头
block: 区块
hash: 哈希
height: 高度
difficulty: 难度
time: 时间
main_pool: 主池
stem_pool: 茎池
data: 数据
size: 大小(GB
peers: 对等节点
error_clean: 节点数据已损坏,需要重新同步。
resync: 重新同步
error_p2p_api: '%{p2p_api} 服务器初始化时发生错误,请通过选择屏幕底部的 %{settings} 检查 %{p2p_api} 设置。'
error_config: '配置初始化时发生错误,请通过选择屏幕底部的 %{settings} 检查设置。'
error_unknown: '初始化时发生未知错误,请通过选择屏幕底部的 %{settings} 检查集成节点设置,或重新同步。'
network_metrics:
loading: 指标将在同步完成后可用
emission: 发行量
inflation: 通胀率
supply: 供应量
block_time: 出块时间
reward: 奖励
difficulty_window: '难度窗口 %{size}'
network_mining:
loading: 挖矿将在同步完成后可用
info: '挖矿服务器已启用,你可以通过选择屏幕底部的 %{settings} 更改其设置。数据在设备连接时更新。'
restart_server_required: 需要重启服务器以应用更改。
rewards_wallet: 奖励接收钱包
server: Stratum 服务器
address: 地址
miners: 矿工
devices: 设备
blocks_found: 已找到区块
hashrate: '算力(C%{bits}'
connected: 已连接
disconnected: 已断开
network_settings:
change_value: 更改值
stratum_ip: 'Stratum IP 地址:'
stratum_port: 'Stratum 端口:'
port_unavailable: 指定的端口不可用
restart_node_required: 需要重启节点以应用更改。
choose_wallet: 选择钱包
stratum_wallet_warning: 必须打开钱包才能接收奖励。
enable: 启用
disable: 禁用
restart: 重启
server: 服务器
api_ip: 'API IP 地址:'
api_port: 'API 端口:'
api_secret: 'Rest API 和 V2 所有者 API 令牌:'
foreign_api_secret: '外部 API 令牌:'
disabled: 已禁用
enabled: 已启用
ftl: '未来时间限制(FTL):'
ftl_description: 相对于节点本地时间(秒),新区块时间戳可以超前的最大限制,超过则区块不被接受。
not_valid_value: 输入的值无效
full_validation: 完整验证
full_validation_description: 是否在处理每个区块时运行完整链验证(同步期间除外)。
archive_mode: 归档模式
archive_mode_desc: 以完整归档模式运行节点(同步需要更多磁盘空间和时间)。
attempt_time: '挖矿尝试时间(秒):'
attempt_time_desc: 在停止并重新从交易池收集交易之前,尝试在特定区块头上挖矿的时间量
min_share_diff: '可接受的最低份额难度:'
reset_settings_desc: 将节点设置重置为默认值
reset_settings: 重置设置
reset: 重置
tx_pool: 交易池
pool_fee: '交易池接受的基础手续费:'
reorg_period: '重组缓存保留期(分钟):'
max_tx_pool: '交易池中最大交易数:'
max_tx_stempool: '茎池中最大交易数:'
max_tx_weight: '可被选中构建区块的最大交易总权重:'
epoch_duration: '纪元持续时间(秒):'
embargo_timer: ' embargo 计时器(秒):'
aggregation_period: '聚合周期(秒):'
stem_probability: '茎阶段概率:'
stem_txs: 茎阶段交易
p2p_server: P2P 服务器
p2p_port: 'P2P 端口:'
add_seed: 添加 DNS 种子节点
seed_address: 'DNS 种子节点地址:'
add_peer: 添加对等节点
peer_address: '对等节点地址:'
peer_address_error: '请输入正确格式的 IP 地址或 DNS 名称(确保指定主机可用),例如:192.168.0.1:1234 或 example.com:5678'
default: 默认
allow_list: 允许列表
allow_list_desc: 仅连接到此列表中的对等节点。
deny_list: 拒绝列表
deny_list_desc: 永不连接到此列表中的对等节点。
favourites: 收藏夹
favourites_desc: 优先连接的对等节点列表。
ban_window: '被封禁对等节点的封禁时长(秒):'
ban_window_desc: 封禁决定由节点根据从对等节点接收数据的正确性做出。
max_inbound_count: '最大入站对等连接数:'
max_outbound_count: '最大出站对等连接数:'
reset_data_desc: 重置节点数据。请谨慎使用,仅在同步出现问题时使用。
reset_data: 重置数据
ip_listen_all: 监听所有接口
modal:
cancel: 取消
save: 保存
add: 添加
modal_exit:
description: 确定要退出应用吗?
exit: 退出
app_settings:
proxy: 代理
proxy_desc: 是否为应用的网络请求使用代理。
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '-'
q:
w:
e:
r:
t: 廿
y:
u:
i:
o:
p:
p1: '"'
a:
s:
d:
f:
g:
h:
j:
k:
l:
l1: \
l2: ':'
z:
x:
c:
v:
b:
n:
m:
m1: ','
m2: .
m3: /
+360
View File
@@ -0,0 +1,360 @@
lang_name: 繁體中文
copy: 複製
paste: 貼上
continue: 繼續
complete: 完成
error: 錯誤
retry: 重試
close: 關閉
change: 變更
show: 顯示
delete: 刪除
clear: 清除
create: 建立
id: 識別碼
kernel: 核心
settings: 設定
language: 語言
scan: 掃描
qr_code: QR 條碼
scan_qr: 掃描 QR 條碼
repeat: 重複
scan_result: 掃描結果
back: 返回
share: 分享
theme: '主題:'
dark: 深色
light: 淺色
file: 檔案
choose_file: 選擇檔案
choose_folder: 選擇資料夾
crash_report: 當機報告
crash_report_warning: 應用程式上次意外關閉,你可以將當機報告分享給開發者。
confirmation: 確認
enter_url: 輸入網址
max_short: 最大
files_location: 檔案位置
moving_files: 正在移動檔案
wrong_path_error: 指定的路徑錯誤
check_updates: 啟動時檢查更新
update_available: 有可用更新!
changelog: '更新日誌:'
wallets:
await_conf_amount: 等待確認
await_fin_amount: 等待最終確認
locked_amount: 已鎖定
txs_empty: '要手動或透過傳輸方式接收資金,請使用畫面底部的 %{message} 或 %{transport} 按鈕;要變更錢包設定,請按 %{settings} 按鈕。'
title: 錢包
create_desc: 建立新錢包或從已儲存的助記詞匯入現有錢包。
add: 新增錢包
name: '名稱:'
pass: '密碼:'
pass_empty: 輸入錢包密碼
current_pass: '目前密碼:'
new_pass: '新密碼:'
min_tx_conf_count: '交易的最少確認數:'
recover: 復原
recovery_phrase: 助記詞
words_count: '單字數量:'
enter_word: '輸入第 %{number} 個單字:'
not_valid_word: 輸入的單字無效
not_valid_phrase: 輸入的助記詞無效
create_phrase_desc: 請安全地寫下並儲存你的助記詞。
restore_phrase_desc: 輸入你儲存的助記詞中的單字。
setup_conn_desc: 選擇錢包連接網路的方式。
conn_method: 連線方式
ext_conn: '外部連線:'
add_node: 新增節點
node_url: '節點網址:'
node_secret: 'API 金鑰(選擇性):'
invalid_url: 輸入的網址無效
open: 開啟錢包
wrong_pass: 輸入的密碼錯誤
locked: 已鎖定
unlocked: 已解鎖
enable_node: '請啟用整合節點以使用錢包,或透過選擇畫面底部的 %{settings} 變更連線設定。'
node_loading: '錢包將在整合節點同步完成後載入,你可以透過選擇畫面底部的 %{settings} 變更連線方式。'
loading: 載入中
closing: 關閉中
checking: 檢查中
default_wallet: 預設錢包
new_account_desc: '輸入新帳戶名稱:'
wallet_loading: 正在載入錢包
wallet_closing: 正在關閉錢包
wallet_checking: 正在檢查錢包
tx_loading: 正在載入交易
default_account: 預設帳戶
accounts: 帳戶
tx_sent: 已傳送
tx_received: 已接收
tx_sending: 傳送中
tx_receiving: 接收中
tx_confirming: 等待確認
tx_canceled: 已取消
tx_cancelling: 取消中
tx_finalizing: 最終確認中
tx_posting: 提交中
tx_confirmed: 已確認
txs: 交易記錄
tx: 交易
messages: 訊息
transport: 傳輸
input_slatepack_desc: '輸入收到的 Slatepack 訊息以建立回應或完成請求:'
parse_slatepack_err: '讀取訊息時發生錯誤,請檢查輸入內容:'
pay_balance_error: '帳戶餘額不足以支付 %{amount} ツ 和網路手續費。'
parse_i1_slatepack_desc: '要支付 %{amount} ツ,請將此訊息傳送給接收方:'
parse_i2_slatepack_desc: '完成交易以接收 %{amount} ツ:'
parse_i3_slatepack_desc: '提交交易以完成 %{amount} ツ 的接收:'
parse_s1_slatepack_desc: '要接收 %{amount} ツ,請將此訊息傳送給發送方:'
parse_s2_slatepack_desc: '完成交易以傳送 %{amount} ツ:'
parse_s3_slatepack_desc: '提交交易以完成 %{amount} ツ 的傳送:'
resp_slatepack_err: '建立回應時發生錯誤,請檢查輸入資料或重試:'
resp_exists_err: 該交易已存在。
resp_canceled_err: 該交易已被取消。
create_request_desc: '建立傳送或接收資金的請求:'
send_request_desc: '你已建立傳送 %{amount} ツ 的請求。請將此訊息傳送給接收方:'
send_slatepack_err: 建立傳送資金請求時發生錯誤,請檢查輸入資料或重試。
invoice_desc: '你已建立接收 %{amount} ツ 的請求。請將此訊息傳送給發送方:'
invoice_slatepack_err: 開立發票時發生錯誤,請檢查輸入資料或重試。
finalize_slatepack_err: '最終確認時發生錯誤,請檢查輸入資料或重試:'
finalize: 最終確認
use_dandelion: 使用蒲公英(Dandelion
enter_amount_send: '你有 %{amount} ツ。輸入傳送金額:'
enter_amount_receive: '輸入接收金額:'
recovery: 復原
repair_wallet: 修復錢包
repair_desc: 檢查錢包,必要時修復和還原遺失的輸出。此操作需要一些時間。
repair_unavailable: 你需要活躍的節點連線並完成錢包同步。
delete: 刪除錢包
delete_conf: 確定要刪除錢包嗎?
delete_desc: 請確保你已儲存助記詞,以便日後存取資金。
wallet_loading_err: '錢包同步時發生錯誤,你可以重試或透過選擇畫面底部的 %{settings} 變更連線設定。'
wallet: 錢包
send: 傳送
receive: 接收
settings: 錢包設定
tx_send_cancel_conf: '確定要取消傳送 %{amount} ツ 嗎?'
tx_receive_cancel_conf: '確定要取消接收 %{amount} ツ 嗎?'
rec_phrase_not_found: 找不到助記詞。
restore_wallet_desc: 如果一般修復無效,可透過刪除所有檔案來還原錢包,你需要重新開啟錢包。
fee_base_desc: '手續費(基礎值%{value}):'
payment_proof: 支付證明
payment_proof_desc: '輸入收到的支付證明以驗證交易:'
payment_proof_valid: '輸入的支付證明有效:'
payment_proof_error: '輸入的支付證明無效:'
tx_delete_confirmation: 確定要從歷史記錄中刪除該交易嗎?
transport:
desc: '使用傳輸方式同步收發訊息:'
tor_network: Tor 網路
connected: 已連線
connecting: 連線中
disconnecting: 中斷中
conn_error: 連線錯誤
disconnected: 已中斷
receiver_address: '接收方位址:'
sender_address: '傳送方位址:'
incorrect_addr_err: '輸入的位址不正確:'
tor_send_error: 透過 Tor 傳送時發生錯誤,請確認接收方在線上,交易已取消。
tor_autorun_desc: 是否在開啟錢包時啟動 Tor 服務以同步接收交易。
tor_sending: 正在透過 Tor 傳送
tor_settings: Tor 設定
bridges: 橋接
bridges_desc: 如果一般連線無法使用,設定橋接以繞過 Tor 網路審查。
bin_file: '二進位檔案:'
conn_line: '連線線路:'
bridges_disabled: 橋接已停用
bridge_name: '橋接 %{b}'
network:
self: 網路
type: '網路類型:'
mainnet: 主網路
testnet: 測試網路
connections: 連線
node: 整合節點
metrics: 指標
mining: 挖礦
settings: 節點設定
enable_node: 啟用節點
autorun: 自動執行
disabled_server: '請啟用整合節點,或按畫面左上角的 %{dots} 新增其他連線方式。'
no_ips: 你的系統上沒有可用的 IP 位址,無法啟動伺服器,請檢查網路連線。
available: 可用
not_available: 不可用
availability_check: 可用性檢查
android_warning: Android 使用者請注意。要成功同步整合節點,你必須在手機系統設定中允許 Grim 應用程式傳送通知,並移除電池使用限制。這是應用程式在背景正常運作所必需的操作。
sync_status:
node_restarting: 節點正在重新啟動
node_down: 節點已停止
initial: 節點正在啟動
no_sync: 節點正在執行
awaiting_peers: 等待對等節點
header_sync: 正在下載區塊頭
header_sync_percent: '正在下載區塊頭:%{percent}%'
tx_hashset_pibd: 正在下載狀態(PIBD
tx_hashset_pibd_percent: '正在下載狀態(PIBD):%{percent}%'
tx_hashset_download: 正在下載狀態
tx_hashset_download_percent: '正在下載狀態:%{percent}%'
tx_hashset_setup_history: '正在準備狀態(歷史):%{percent}%'
tx_hashset_setup_position: '正在準備狀態(位置):%{percent}%'
tx_hashset_setup: 正在準備狀態
tx_hashset_range_proofs_validation: '正在驗證狀態(範圍證明):%{percent}%'
tx_hashset_kernels_validation: '正在驗證狀態(核心):%{percent}%'
tx_hashset_save: 正在最終確認鏈狀態
body_sync: 正在下載區塊
body_sync_percent: '正在下載區塊:%{percent}%'
shutdown: 節點正在關閉
network_node:
header: 區塊頭
block: 區塊
hash: 雜湊
height: 高度
difficulty: 難度
time: 時間
main_pool: 主池
stem_pool: 莖池
data: 資料
size: 大小(GB
peers: 對等節點
error_clean: 節點資料已損毀,需要重新同步。
resync: 重新同步
error_p2p_api: '%{p2p_api} 伺服器初始化時發生錯誤,請透過選擇畫面底部的 %{settings} 檢查 %{p2p_api} 設定。'
error_config: '設定初始化時發生錯誤,請透過選擇畫面底部的 %{settings} 檢查設定。'
error_unknown: '初始化時發生未知錯誤,請透過選擇畫面底部的 %{settings} 檢查整合節點設定,或重新同步。'
network_metrics:
loading: 指標將在同步完成後可用
emission: 發行量
inflation: 通膨率
supply: 供應量
block_time: 出塊時間
reward: 獎勵
difficulty_window: '難度視窗 %{size}'
network_mining:
loading: 挖礦將在同步完成後可用
info: '挖礦伺服器已啟用,你可以透過選擇畫面底部的 %{settings} 變更其設定。資料在裝置連線時更新。'
restart_server_required: 需要重新啟動伺服器以套用變更。
rewards_wallet: 獎勵接收錢包
server: Stratum 伺服器
address: 位址
miners: 礦工
devices: 裝置
blocks_found: 已找到區塊
hashrate: '算力(C%{bits}'
connected: 已連線
disconnected: 已中斷
network_settings:
change_value: 變更值
stratum_ip: 'Stratum IP 位址:'
stratum_port: 'Stratum 連接埠:'
port_unavailable: 指定的連接埠無法使用
restart_node_required: 需要重新啟動節點以套用變更。
choose_wallet: 選擇錢包
stratum_wallet_warning: 必須開啟錢包才能接收獎勵。
enable: 啟用
disable: 停用
restart: 重新啟動
server: 伺服器
api_ip: 'API IP 位址:'
api_port: 'API 連接埠:'
api_secret: 'Rest API 和 V2 擁有者 API 權杖:'
foreign_api_secret: '外部 API 權杖:'
disabled: 已停用
enabled: 已啟用
ftl: '未來時間限制(FTL):'
ftl_description: 相對於節點本地時間(秒),新區塊時間戳可以超前的最大限制,超過則區塊不被接受。
not_valid_value: 輸入的值無效
full_validation: 完整驗證
full_validation_description: 是否在處理每個區塊時執行完整鏈驗證(同步期間除外)。
archive_mode: 封存模式
archive_mode_desc: 以完整封存模式執行節點(同步需要更多磁碟空間和時間)。
attempt_time: '挖礦嘗試時間(秒):'
attempt_time_desc: 在停止並重新從交易池收集交易之前,嘗試在特定區塊頭上挖礦的時間量
min_share_diff: '可接受的最低份額難度:'
reset_settings_desc: 將節點設定重設為預設值
reset_settings: 重設設定
reset: 重設
tx_pool: 交易池
pool_fee: '交易池接受的基礎手續費:'
reorg_period: '重組快取保留期(分鐘):'
max_tx_pool: '交易池中最大交易數:'
max_tx_stempool: '莖池中最大交易數:'
max_tx_weight: '可被選中建構區塊的最大交易總權重:'
epoch_duration: '紀元持續時間(秒):'
embargo_timer: 'Embargo 計時器(秒):'
aggregation_period: '彙總週期(秒):'
stem_probability: '莖階段機率:'
stem_txs: 莖階段交易
p2p_server: P2P 伺服器
p2p_port: 'P2P 連接埠:'
add_seed: 新增 DNS 種子節點
seed_address: 'DNS 種子節點位址:'
add_peer: 新增對等節點
peer_address: '對等節點位址:'
peer_address_error: '請輸入正確格式的 IP 位址或 DNS 名稱(確保指定主機可用),例如:192.168.0.1:1234 或 example.com:5678'
default: 預設
allow_list: 允許清單
allow_list_desc: 僅連線到此清單中的對等節點。
deny_list: 拒絕清單
deny_list_desc: 永不連線到此清單中的對等節點。
favourites: 我的最愛
favourites_desc: 優先連線的對等節點清單。
ban_window: '被封鎖對等節點的封鎖時長(秒):'
ban_window_desc: 封鎖決定由節點根據從對等節點接收資料的正確性做出。
max_inbound_count: '最大入站對等連線數:'
max_outbound_count: '最大出站對等連線數:'
reset_data_desc: 重設節點資料。請謹慎使用,僅在同步發生問題時使用。
reset_data: 重設資料
ip_listen_all: 監聽所有介面
modal:
cancel: 取消
save: 儲存
add: 新增
modal_exit:
description: 確定要離開應用程式嗎?
exit: 離開
app_settings:
proxy: 代理
proxy_desc: 是否為應用程式的網路請求使用代理。
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '-'
q:
w:
e:
r:
t: 廿
y:
u:
i:
o:
p:
p1: '"'
a:
s:
d:
f:
g:
h:
j:
k:
l:
l1: \
l2: ':'
z:
x:
c:
v:
b:
n:
m:
m1: ','
m2: .
m3: /
+63 -47
View File
@@ -1,49 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key>
<key>CFBundleDisplayName</key> <string>Grim</string>
<string>Grim</string> <key>CFBundleExecutable</key>
<string>grim</string>
<key>CFBundleExecutable</key> <key>CFBundleIconFile</key>
<string>grim</string> <string>AppIcon</string>
<key>CFBundleIconName</key>
<key>CFBundleIconFile</key> <string>AppIcon</string>
<string>AppIcon</string> <key>CFBundleIdentifier</key>
<string>mw.gri.macos</string>
<key>CFBundleIconName</key> <key>CFBundleInfoDictionaryVersion</key>
<string>AppIcon</string> <string>6.0</string>
<key>CFBundleName</key>
<key>CFBundleIdentifier</key> <string>Grim</string>
<string>mw.gri.macos</string> <key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleShortVersionString</key>
<string>6.0</string> <string>0.3.6</string>
<key>CFBundleSupportedPlatforms</key>
<key>CFBundleName</key> <array>
<string>Grim</string> <string>MacOSX</string>
</array>
<key>CFBundlePackageType</key> <key>CFBundleVersion</key>
<string>APPL</string> <string>1</string>
<key>NSCameraUsageDescription</key>
<key>CFBundleShortVersionString</key> <string>Grim needs an access to your camera to scan QR code.</string>
<string>0.1.0</string> <key>CFBundleDocumentTypes</key>
<array>
<key>CFBundleSupportedPlatforms</key> <dict>
<array> <key>CFBundleTypeName</key>
<string>MacOSX</string> <string>Apple SimpleText document</string>
</array> <key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleVersion</key> <key>LSItemContentTypes</key>
<string>1</string> <array>
<string>com.apple.traditional-mac-plain-text</string>
<key>LSApplicationCategoryType</key> </array>
<string>public.app-category.finance</string> <key>NSDocumentClass</key>
<string>Document</string>
<key>NSHumanReadableCopyright</key> </dict>
<string>2024</string> <dict>
</dict> <key>CFBundleTypeName</key>
</plist> <string>Unknown document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>public.data</string>
</array>
<key>NSDocumentClass</key>
<string>Document</string>
</dict>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>NSHumanReadableCopyright</key>
<string>2024</string>
</dict>
</plist>
+2
View File
@@ -0,0 +1,2 @@
!.gitignore
grim
+15 -22
View File
@@ -1,22 +1,21 @@
#!/bin/bash #!/bin/bash
set -e set -e
case $2 in case $1 in
x86_64|arm|universal) x86_64|arm|universal)
;; ;;
*) *)
echo "Usage: release_macos.sh [version] [platform]\n - platform: 'x86_64', 'arm', 'universal'" >&2 echo "Usage: release_macos.sh [platform] [version]\n - platform: 'x86_64', 'arm', 'universal'" >&2
exit 1 exit 1
esac esac
if [[ ! -v SDKROOT ]]; then if [[ "$OSTYPE" != "darwin"* ]]; then
if [ -z ${SDKROOT+x} ]; then
echo "MacOS SDKROOT is not set" echo "MacOS SDKROOT is not set"
exit 1 exit 1
elif [[ -z "SDKROOT" ]]; then else
echo "MacOS SDKROOT is set to the empty string"
exit 1
else
echo "Use MacOS SDK: ${SDKROOT}" echo "Use MacOS SDK: ${SDKROOT}"
fi
fi fi
# Setup build directory # Setup build directory
@@ -25,31 +24,25 @@ cd ${BASEDIR}
cd .. cd ..
# Setup platform # Setup platform
[[ $1 == "x86_64" ]] && arch+=(x86_64-apple-darwin)
[[ $1 == "arm" ]] && arch+=(aarch64-apple-darwin)
rustup target add x86_64-apple-darwin rustup target add x86_64-apple-darwin
rustup target add aarch64-apple-darwin rustup target add aarch64-apple-darwin
[[ $1 == "universal" ]]; arch+=(universal2-apple-darwin)
rm -rf target/x86_64-apple-darwin
rm -rf target/aarch64-apple-darwin
[[ $2 == "x86_64" ]] && arch+=(x86_64-apple-darwin)
[[ $2 == "arm" ]] && arch+=(aarch64-apple-darwin)
[[ $2 == "universal" ]] && arch+=(universal2-apple-darwin)
# Start release build with zig linker for cross-compilation
# zig 0.12+ required
cargo install cargo-zigbuild cargo install cargo-zigbuild
cargo zigbuild --release --target ${arch} cargo zigbuild --release --target ${arch}
rm -rf .intentionally-empty-file.o
mkdir macos/Grim.app/Contents/MacOS rm -f .intentionally-empty-file.o
yes | cp -rf target/${arch}/release/grim macos/Grim.app/Contents/MacOS yes | cp -rf target/${arch}/release/grim macos/Grim.app/Contents/MacOS
### Sign .app resources on change: # Sign .app resources on change:
#rcodesign generate-self-signed-certificate #rcodesign generate-self-signed-certificate
#rcodesign sign --pem-file cert.pem macos/Grim.app #rcodesign sign --pem-file cert.pem macos/Grim.app
# Create release package # Create release package
FILE_NAME=grim-v$1-macos-$2.zip FILE_NAME=grim-v$2-macos-$1.zip
rm -rf target/${arch}/release/${FILE_NAME}
cd macos cd macos
zip -r ${FILE_NAME} Grim.app zip -r ${FILE_NAME} Grim.app
mv ${FILE_NAME} ../target/${arch}/release mv ${FILE_NAME} ../target/${arch}/release
+2
View File
@@ -0,0 +1,2 @@
hard_tabs = true
edition = "2024"
+110 -64
View File
@@ -1,81 +1,127 @@
#!/bin/bash #!/bin/bash
usage="Usage: build_run_android.sh [type] [platform]\n - type: 'debug', 'release'\n - platform: 'v7', 'v8'" usage="Usage: android.sh [type] [platform|version] [flavor]\n - type: 'build' to run locally, 'lib' - .so for all platforms, 'release' - .apk for all platforms\n - platform, for 'build' type: 'v7', 'v8', 'x86'\n - version for 'lib' and 'release', example: '0.2.2'\n - optional flavor, for non-'lib' type: 'ci' for local maven, default - 'local' for external"
case $1 in case $1 in
debug|release) build|lib|release)
;; ;;
*) *)
printf "$usage" printf "$usage"
exit 1 exit 1
esac esac
case $2 in if [[ $1 == "build" ]]; then
v7|v8) case $2 in
;; v7|v8|x86)
*) ;;
printf "$usage" *)
exit 1 printf "$usage"
esac exit 1
esac
# Setup build directory
BASEDIR=$(cd $(dirname $0) && pwd)
cd ${BASEDIR}
cd ..
# Setup release argument
type=$1
[[ ${type} == "release" ]] && release_param="--profile release-apk"
# Setup platform argument
[[ $2 == "v7" ]] && arch+=(armeabi-v7a)
[[ $2 == "v8" ]] && arch+=(arm64-v8a)
# Setup platform path
[[ $2 == "v7" ]] && platform+=(armv7-linux-androideabi)
[[ $2 == "v8" ]] && platform+=(aarch64-linux-android)
# Install platform
[[ $2 == "v7" ]] && rustup target install armv7-linux-androideabi
[[ $2 == "v8" ]] && rustup target install aarch64-linux-android
# Build native code
cargo install cargo-ndk
sed -i -e 's/"rlib"/"cdylib","rlib"/g' Cargo.toml
# temp fix for https://stackoverflow.com/questions/57193895/error-use-of-undeclared-identifier-pthread-mutex-robust-cargo-build-liblmdb-s
success=0
export CPPFLAGS="-DMDB_USE_ROBUST=0" && export CFLAGS="-DMDB_USE_ROBUST=0"
cargo ndk -t ${arch} build ${release_param}
unset CPPFLAGS && unset CFLAGS
cargo ndk -t ${arch} -o android/app/src/main/jniLibs build ${release_param}
if [ $? -eq 0 ]
then
success=1
fi fi
sed -i -e 's/"cdylib","rlib"/"rlib"/g' Cargo.toml # Setup build directory
BASEDIR=$(cd "$(dirname "$0")" && pwd)
cd "${BASEDIR}" || exit 1
cd ..
# Build Android application and launch at all connected devices # Install platforms and tools
if [ $success -eq 1 ] rustup target add armv7-linux-androideabi
then rustup target add aarch64-linux-android
cd android rustup target add x86_64-linux-android
cargo install cargo-ndk
# Setup gradle argument success=1
[[ $1 == "release" ]] && gradle_param+=(assembleRelease)
[[ $1 == "debug" ]] && gradle_param+=(build)
### Build native code
function build_lib() {
[[ $1 == "v7" ]] && arch=armeabi-v7a
[[ $1 == "v8" ]] && arch=arm64-v8a
[[ $1 == "x86" ]] && arch=x86_64
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
sed -i -e 's/"rlib"]/"cdylib","rlib"]/g' Cargo.toml
cargo ndk -t "${arch}" -o android/app/src/main/jniLibs build --profile release-apk
if [ $? -ne 0 ]; then
success=0
fi
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
rm -f Cargo.toml-e
}
### Build application
function build_apk() {
flavor=$3
[[ flavor == "" ]] && flavor="local"
cd android || exit 1
./gradlew clean ./gradlew clean
./gradlew ${gradle_param} # Build signed apk if keystore exists
if [ ! -f keystore.properties ]; then
./gradlew assemble${flavor}Debug
if [ $? -ne 0 ]; then
success=0
fi
apk_path=app/build/outputs/apk/${flavor}/debug/app-${flavor}-debug.apk
else
./gradlew assemble${flavor}SignedRelease
if [ $? -ne 0 ]; then
success=0
fi
apk_path=app/build/outputs/apk/${flavor}/signedRelease/app-${flavor}-signedRelease.apk
fi
# Setup apk path if [[ $1 == "" ]] && [ $success -eq 1 ]; then
[[ $1 == "release" ]] && apk_path+=(app/build/outputs/apk/release/app-release.apk) # Launch application at all connected devices.
[[ $1 == "debug" ]] && apk_path+=(app/build/outputs/apk/debug/app-debug.apk) for SERIAL in $(adb devices | grep -v List | cut -f 1);
do
adb -s "$SERIAL" install ${apk_path}
sleep 1s
adb -s "$SERIAL" shell am start -n mw.gri.android/.MainActivity;
done
elif [ $success -eq 1 ]; then
# Get version
version=$2
if [[ -z "$version" ]]; then
version=v$(grep -m 1 -Po 'version = "\K[^"]*' ../Cargo.toml)
fi
# Setup release file name
name=grim-${version}-android-$1.apk
[[ $1 == "arm" ]] && name=grim-${version}-android.apk
rm -f "${name}"
mv ${apk_path} "${name}"
# Calculate checksum
checksum=grim-${version}-android-$1-sha256sum.txt
[[ $1 == "arm" ]] && checksum=grim-${version}-android-sha256sum.txt
rm -f "${checksum}"
sha256sum "${name}" > "${checksum}"
fi
for SERIAL in $(adb devices | grep -v List | cut -f 1); cd ..
do }
adb -s $SERIAL install ${apk_path}
sleep 1s rm -rf android/app/src/main/jniLibs/*
adb -s $SERIAL shell am start -n mw.gri.android/.MainActivity; if [[ $1 == "lib" ]]; then
done build_lib "v7"
fi [ $success -eq 1 ] && build_lib "v8"
[ $success -eq 1 ] && build_lib "x86"
[ $success -eq 1 ] && exit 0
elif [[ $1 == "build" ]]; then
build_lib "$2"
[ $success -eq 1 ] && build_apk "" "" "$3"
[ $success -eq 1 ] && exit 0
else
rm -rf target/release-apk
rm -rf target/aarch64-linux-android
rm -rf target/x86_64-linux-android
rm -rf target/armv7-linux-androideabi
build_lib "v7"
[ $success -eq 1 ] && build_lib "v8"
[ $success -eq 1 ] && build_apk "arm" "$2" "$3"
rm -rf android/app/src/main/jniLibs/*
[ $success -eq 1 ] && build_lib "x86"
[ $success -eq 1 ] && build_apk "x86_64" "$2" "$3"
[ $success -eq 1 ] && exit 0
fi
exit 1
+10 -8
View File
@@ -1,25 +1,27 @@
#!/bin/bash #!/bin/bash
case $1 in case $1 in
debug|release) debug|build)
;; ;;
*) *)
echo "Usage: build_run.sh [type] where is type is 'debug' or 'release'" >&2 echo "Usage: build_run.sh [type] where is type is 'debug' or 'build'" >&2
exit 1 exit 1
esac esac
# Setup build directory # Setup build directory
BASEDIR=$(cd $(dirname $0) && pwd) BASEDIR=$(cd "$(dirname $0)" && pwd)
cd ${BASEDIR} cd "${BASEDIR}" || return
cd .. cd ..
# Build application # Build application
type=$1 type=$1
[[ ${type} == "release" ]] && release_param+=(--release) [[ ${type} == "build" ]] && release_param+=(--release)
cargo build ${release_param[@]} cargo --config profile.release.incremental=true build "${release_param[@]}"
# Start application # Start application
if [ $? -eq 0 ] if [ $? -eq 0 ]
then then
./target/${type}/grim path=${type}
fi [[ ${type} == "build" ]] && path="release"
./target/"${path}"/grim
fi
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# Usage to bump version
# ./version.sh patch
# ./version.sh minor
# ./version.sh major
# Setup base directory
BASEDIR=$(cd $(dirname $0) && pwd)
cd ${BASEDIR}
cd ..
# Exit script if command fails or uninitialized variables used
set -euo pipefail
# ==================================
# Verify repo is clean
# ==================================
# List uncommitted changes and
# check if the output is not empty
if [ -n "$(git status --porcelain)" ]; then
# Print error message
printf "\nError: repo has uncommitted changes\n\n"
# Exit with error code
exit 1
fi
# ==================================
# Get latest version from git tags
# ==================================
# List git tags sorted lexicographically
# so version numbers sorted correctly
GIT_TAGS=$(git tag --sort=version:refname)
# Get last line of output which returns the
# last tag (most recent version)
GIT_TAG_LATEST=$(echo "$GIT_TAGS" | tail -n 1)
# If no tag found, default to v0.1.0
if [ -z "$GIT_TAG_LATEST" ]; then
GIT_TAG_LATEST="v0.1.0"
fi
# Strip prefix 'v' from the tag to easily increment
GIT_TAG_LATEST=$(echo "$GIT_TAG_LATEST" | sed 's/^v//')
# ==================================
# Increment version number
# ==================================
# Get version type from first argument passed to script
VERSION_TYPE="${1-}"
VERSION_NEXT=""
if [ "$VERSION_TYPE" = "patch" ]; then
# Increment patch version
VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$NF++; print $1"."$2"."$NF}')"
elif [ "$VERSION_TYPE" = "minor" ]; then
# Increment minor version
VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$2++; $3=0; print $1"."$2"."$3}')"
elif [ "$VERSION_TYPE" = "major" ]; then
# Increment major version
VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$1++; $2=0; $3=0; print $1"."$2"."$3}')"
else
# Print error for unknown versioning type
printf "\nError: invalid VERSION_TYPE arg passed, must be 'patch', 'minor' or 'major'\n\n"
# Exit with error code
exit 1
fi
# Update MacOS version.
sed -i '' -e 's/'"$GIT_TAG_LATEST"'/'"$VERSION_NEXT"'/' macos/Grim.app/Contents/Info.plist
# Update version for Windows installer.
sed -i '' -e 's/" Version="[^\"]*"/" Version="'"$VERSION_NEXT"'"/g' wix/main.wxs
sed -i '' -e 's/<Package Id="[^\"]*"/<Package Id="'"$(uuidgen)"'"/g' wix/main.wxs
# Update Android version in build.gradle
sed -i'.bak' -e 's/versionName [0-9a-zA-Z -_]*/versionName "'"$VERSION_NEXT"'"/' android/app/build.gradle
rm -f android/app/build.gradle.bak
# Update version in Cargo.toml
sed -i'.bak' -e "s/^version = .*/version = \"$VERSION_NEXT\"/" Cargo.toml
rm -f Cargo.toml.bak
# Update Cargo.lock as this changes when
# updating the version in your manifest
cargo update -p grim
# Commit the changes
git add .
git commit -m "build: version $VERSION_NEXT"
# ==================================
# Create git tag for new version
# ==================================
# Create a tag and push to master branch
#git tag "v$VERSION_NEXT" master
#git push origin master --follow-tags
+70
View File
@@ -0,0 +1,70 @@
@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
@@ -0,0 +1,51 @@
#!/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
Regular → Executable
+372 -350
View File
@@ -12,408 +12,430 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use std::sync::atomic::{AtomicBool, Ordering}; use egui::epaint::RectShape;
use egui::{
Align, Context, CornerRadius, CursorIcon, LayerId, Layout, Modifiers, Order, ResizeDirection,
Stroke, StrokeKind, UiBuilder, ViewportCommand,
};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use egui::{Align, Context, CursorIcon, Layout, Modifiers, Rect, ResizeDirection, Rounding, Stroke, ViewportCommand}; use std::sync::atomic::{AtomicBool, Ordering};
use egui::epaint::{RectShape};
use egui::os::OperatingSystem;
use crate::AppConfig; use crate::AppConfig;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::{ARROWS_IN, ARROWS_OUT, CARET_DOWN, MOON, SUN, X}; use crate::gui::icons::{ARROWS_IN, ARROWS_OUT, CARET_DOWN, MOON, SUN, X};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Content, TitlePanel, View}; use crate::gui::views::types::ContentContainer;
use crate::gui::views::{Content, KeyboardContent, Modal, TitlePanel, View};
lazy_static! { lazy_static! {
/// State to check if platform Back button was pressed. /// State to check if platform Back button was pressed.
static ref BACK_BUTTON_PRESSED: AtomicBool = AtomicBool::new(false); static ref BACK_BUTTON_PRESSED: AtomicBool = AtomicBool::new(false);
} }
/// Implements ui entry point and contains platform-specific callbacks. /// Implements ui entry point and contains platform-specific callbacks.
pub struct App<Platform> { pub struct App<Platform> {
/// Platform specific callbacks handler. /// Handles platform-specific functionality.
pub(crate) platform: Platform, pub platform: Platform,
/// Main ui content. /// Main content.
content: Content, content: Content,
/// Last window resize direction. /// Last window resize direction.
resize_direction: Option<ResizeDirection> resize_direction: Option<ResizeDirection>,
/// Flag to check if it's first draw.
first_draw: bool,
} }
impl<Platform: PlatformCallbacks> App<Platform> { impl<Platform: PlatformCallbacks> App<Platform> {
pub fn new(platform: Platform) -> Self { pub fn new(platform: Platform) -> Self {
Self { platform, content: Content::default(), resize_direction: None } Self {
} platform,
content: Content::default(),
resize_direction: None,
first_draw: true,
}
}
/// Draw application content. /// Called of first content draw.
pub fn ui(&mut self, ctx: &Context) { fn on_first_draw(&mut self, ctx: &Context) {
// Handle Esc keyboard key event and platform Back button key event. // Set platform context.
let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed); if View::is_desktop() {
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::Escape)) || back_pressed { self.platform.set_context(ctx);
self.content.on_back(); }
if back_pressed { // Setup visuals.
BACK_BUTTON_PRESSED.store(false, Ordering::Relaxed); crate::setup_fonts(ctx);
} crate::setup_visuals(ctx);
// Request repaint to update previous content. }
ctx.request_repaint();
}
// Handle Close event (on desktop). /// Draw application content.
if ctx.input(|i| i.viewport().close_requested()) { pub fn ui(&mut self, ctx: &Context) {
if !self.content.exit_allowed { if self.first_draw {
ctx.send_viewport_cmd(ViewportCommand::CancelClose); self.on_first_draw(ctx);
Content::show_exit_modal(); self.first_draw = false;
} else { }
ctx.input(|i| {
if let Some(rect) = i.viewport().inner_rect {
AppConfig::save_window_size(rect.width(), rect.height());
}
if let Some(rect) = i.viewport().outer_rect {
AppConfig::save_window_pos(rect.left(), rect.top());
}
});
}
}
// Show main content with custom frame on desktop. // Handle Esc keyboard key event and platform Back button key event.
egui::CentralPanel::default() let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed);
.frame(egui::Frame { if back_pressed
..Default::default() || ctx.input_mut(|i| {
}) i.consume_key(Modifiers::NONE, egui::Key::Escape)
.show(ctx, |ui| { || i.consume_key(Modifiers::NONE, egui::Key::BrowserBack)
let is_mac_os = OperatingSystem::from_target_os() == OperatingSystem::Mac; }) {
if View::is_desktop() && !is_mac_os { // Pass event to content.
self.desktop_window_ui(ui); self.content.on_back(ctx, &self.platform);
} else { if back_pressed {
if is_mac_os { BACK_BUTTON_PRESSED.store(false, Ordering::Relaxed);
self.window_title_ui(ui); }
ui.add_space(-1.0); // Request repaint to update previous content.
} ctx.request_repaint();
self.content.ui(ui, &self.platform); }
}
});
}
/// Draw custom resizeable window content. // Handle Close event on desktop.
fn desktop_window_ui(&mut self, ui: &mut egui::Ui) { if View::is_desktop() && ctx.input(|i| i.viewport().close_requested()) {
let is_fullscreen = ui.ctx().input(|i| { if !self.content.exit_allowed {
i.viewport().fullscreen.unwrap_or(false) ctx.send_viewport_cmd(ViewportCommand::CancelClose);
}); Content::show_exit_modal();
} else {
let (w, h) = View::window_size(ctx);
AppConfig::save_window_size(w, h);
ctx.input(|i| {
if let Some(rect) = i.viewport().outer_rect {
AppConfig::save_window_pos(rect.left(), rect.top());
}
});
}
}
let title_stroke_rect = { // Show main content.
let mut rect = ui.max_rect(); egui::CentralPanel::default()
if !is_fullscreen { .frame(egui::Frame {
rect = rect.shrink(Content::WINDOW_FRAME_MARGIN); ..Default::default()
} })
rect.max.y = if !is_fullscreen { .show(ctx, |ui| {
Content::WINDOW_FRAME_MARGIN if View::is_desktop() {
} else { let is_fullscreen =
0.0 ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false));
} + Content::WINDOW_TITLE_HEIGHT + TitlePanel::DEFAULT_HEIGHT + 0.5; let os = egui::os::OperatingSystem::from_target_os();
rect match os {
}; egui::os::OperatingSystem::Mac => {
let title_stroke = RectShape { self.window_title_ui(ui, is_fullscreen);
rect: title_stroke_rect, ui.add_space(-1.0);
rounding: Rounding { Self::title_panel_bg(ui, true);
nw: 8.0, self.content.ui(ui, &self.platform);
ne: 8.0, }
sw: 0.0, egui::os::OperatingSystem::Windows => {
se: 0.0, Self::title_panel_bg(ui, false);
}, self.content.ui(ui, &self.platform);
fill: Colors::yellow(), }
stroke: Stroke { _ => {
width: 1.0, self.custom_frame_ui(ui, is_fullscreen);
color: egui::Color32::from_gray(200) }
}, }
blur_width: 0.0, } else {
fill_texture_id: Default::default(), Self::title_panel_bg(ui, false);
uv: Rect::ZERO self.content.ui(ui, &self.platform);
}; }
// Draw title stroke. });
ui.painter().add(title_stroke);
let content_stroke_rect = { // Check if desktop window was focused after requested attention.
let mut rect = ui.max_rect(); if self.platform.user_attention_required()
if !is_fullscreen { && ctx.input(|i| i.viewport().focused.unwrap_or(true))
rect = rect.shrink(Content::WINDOW_FRAME_MARGIN); {
} self.platform.clear_user_attention();
let top = Content::WINDOW_TITLE_HEIGHT + TitlePanel::DEFAULT_HEIGHT + 0.5; }
rect.min += egui::vec2(0.0, top);
rect
};
let content_stroke = RectShape {
rect: content_stroke_rect,
rounding: Rounding::ZERO,
fill: Colors::fill(),
stroke: Stroke {
width: 1.0,
color: Colors::stroke()
},
blur_width: 0.0,
fill_texture_id: Default::default(),
uv: Rect::ZERO
};
// Draw content stroke.
ui.painter().add(content_stroke);
// Draw window content. // Show modal or keyboard window above opened Modal.
let mut content_rect = ui.max_rect(); if Modal::opened().is_some() {
if !is_fullscreen { ctx.move_to_top(LayerId::new(Order::Middle, egui::Id::new(Modal::WINDOW_ID)));
content_rect = content_rect.shrink(Content::WINDOW_FRAME_MARGIN); let keyboard_showing = if let Some(l) = ctx.top_layer_id() {
} l.id == egui::Id::new(KeyboardContent::WINDOW_ID)
ui.allocate_ui_at_rect(content_rect, |ui| { } else {
self.window_title_ui(ui); false
self.window_content(ui); };
}); 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();
}
}
// Setup resize areas. /// Draw custom desktop window frame content.
if !is_fullscreen { fn custom_frame_ui(&mut self, ui: &mut egui::Ui, is_fullscreen: bool) {
self.resize_area_ui(ui, ResizeDirection::North); let content_bg_rect = {
self.resize_area_ui(ui, ResizeDirection::East); let mut r = ui.max_rect();
self.resize_area_ui(ui, ResizeDirection::South); if !is_fullscreen {
self.resize_area_ui(ui, ResizeDirection::West); r = r.shrink(Content::WINDOW_FRAME_MARGIN);
self.resize_area_ui(ui, ResizeDirection::NorthWest); }
self.resize_area_ui(ui, ResizeDirection::NorthEast); r.min.y += Content::WINDOW_TITLE_HEIGHT + TitlePanel::HEIGHT;
self.resize_area_ui(ui, ResizeDirection::SouthEast); r
self.resize_area_ui(ui, ResizeDirection::SouthWest); };
} 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 window content for desktop. let mut content_rect = ui.max_rect();
fn window_content(&mut self, ui: &mut egui::Ui) { if !is_fullscreen {
let content_rect = { content_rect = content_rect.shrink(Content::WINDOW_FRAME_MARGIN);
let mut rect = ui.max_rect(); }
rect.min.y += Content::WINDOW_TITLE_HEIGHT; // Draw window content.
rect ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| {
}; // Draw window title.
// Draw main content. self.window_title_ui(ui, is_fullscreen);
let mut content_ui = ui.child_ui(content_rect, *ui.layout(), None); ui.add_space(-1.0);
self.content.ui(&mut content_ui, &self.platform);
}
/// Draw custom window title content. // Draw title panel background.
fn window_title_ui(&self, ui: &mut egui::Ui) { Self::title_panel_bg(ui, true);
let content_rect = ui.max_rect();
let title_rect = { let content_rect = {
let mut rect = content_rect; let mut rect = ui.max_rect();
rect.max.y = rect.min.y + Content::WINDOW_TITLE_HEIGHT; rect.min.y += Content::WINDOW_TITLE_HEIGHT;
rect rect
}; };
let mut content_ui =
ui.new_child(UiBuilder::new().max_rect(content_rect).layout(*ui.layout()));
// Draw main content.
self.content.ui(&mut content_ui, &self.platform);
});
let is_fullscreen = ui.ctx().input(|i| { // Setup resize areas.
i.viewport().fullscreen.unwrap_or(false) 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 window_title_bg = RectShape { /// Draw title panel background.
rect: title_rect, fn title_panel_bg(ui: &mut egui::Ui, window_title: bool) {
rounding: if is_fullscreen { let title_rect = {
Rounding::ZERO let mut rect = ui.max_rect();
} else { if window_title {
Rounding { rect.min.y += Content::WINDOW_TITLE_HEIGHT - 0.5;
nw: 8.0, }
ne: 8.0, rect.max.y = rect.min.y + View::get_top_inset() + TitlePanel::HEIGHT;
sw: 0.0, rect
se: 0.0, };
} let title_bg = RectShape::filled(title_rect, CornerRadius::ZERO, Colors::yellow());
}, ui.painter().add(title_bg);
fill: Colors::yellow_dark(), }
stroke: Stroke::NONE,
blur_width: 0.0,
fill_texture_id: Default::default(),
uv: Rect::ZERO
};
// Draw title background.
ui.painter().add(window_title_bg);
let painter = ui.painter(); /// 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 interact_rect = { let title_bg_rect = {
let mut rect = title_rect; let mut r = title_rect.clone();
if !is_fullscreen { r.max.y += TitlePanel::HEIGHT - 1.0;
rect.min.y += Content::WINDOW_FRAME_MARGIN; r
} };
rect let is_mac = egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac;
}; let window_title_bg = RectShape::new(
let title_resp = ui.interact( title_bg_rect,
interact_rect, if is_fullscreen || is_mac {
egui::Id::new("window_title"), CornerRadius::ZERO
egui::Sense::click_and_drag(), } 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);
// Paint the title. let painter = ui.painter();
let dual_wallets_panel =
ui.available_width() >= (Content::SIDE_PANEL_WIDTH * 3.0) + View::get_right_inset();
let wallet_panel_opened = self.content.wallets.wallet_panel_opened();
let hide_app_name = if dual_wallets_panel {
!wallet_panel_opened || (AppConfig::show_wallets_at_dual_panel() &&
self.content.wallets.showing_wallet() && !self.content.wallets.creating_wallet())
} else if Content::is_dual_panel_mode(ui) {
!wallet_panel_opened
} else {
!Content::is_network_panel_open() && !wallet_panel_opened
};
let title_text = if hide_app_name {
"".to_string()
} else {
format!("Grim {}", crate::VERSION)
};
painter.text(
title_rect.center(),
egui::Align2::CENTER_CENTER,
title_text,
egui::FontId::proportional(15.0),
Colors::title(true),
);
// Interact with the window title (drag to move window): let interact_rect = {
if !is_fullscreen && title_resp.double_clicked() { let mut rect = title_rect.clone();
ui.ctx().send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen)); 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);
}
if !is_fullscreen && title_resp.drag_started_by(egui::PointerButton::Primary) { // Paint the title.
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); 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),
);
ui.allocate_ui_at_rect(title_rect, |ui| { ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Draw button to close window. // Draw button to close window.
View::title_button_small(ui, X, |_| { View::title_button_small(ui, X, |_| {
Content::show_exit_modal(); if Modal::opened().is_none() || Modal::opened_closeable() {
}); Content::show_exit_modal();
}
});
// Draw fullscreen button. // Draw fullscreen button.
let fullscreen_icon = if is_fullscreen { let fullscreen_icon = if is_fullscreen { ARROWS_IN } else { ARROWS_OUT };
ARROWS_IN View::title_button_small(ui, fullscreen_icon, |ui| {
} else { ui.ctx()
ARROWS_OUT .send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen));
}; });
View::title_button_small(ui, fullscreen_icon, |ui| {
ui.ctx().send_viewport_cmd(ViewportCommand::Fullscreen(!is_fullscreen));
});
// Draw button to minimize window. // Draw button to minimize window.
View::title_button_small(ui, CARET_DOWN, |ui| { View::title_button_small(ui, CARET_DOWN, |ui| {
ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)); ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true));
}); });
// Draw application icon. // Draw application icon.
let layout_size = ui.available_size(); let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| { ui.allocate_ui_with_layout(
// Draw button to minimize window. layout_size,
let use_dark = AppConfig::dark_theme().unwrap_or(false); Layout::left_to_right(Align::Center),
let theme_icon = if use_dark { |ui| {
SUN // Draw button to minimize window.
} else { let use_dark = AppConfig::dark_theme().unwrap_or(false);
MOON let theme_icon = if use_dark { SUN } else { MOON };
}; View::title_button_small(ui, theme_icon, |ui| {
View::title_button_small(ui, theme_icon, |ui| { AppConfig::set_dark_theme(!use_dark);
AppConfig::set_dark_theme(!use_dark); crate::setup_visuals(ui.ctx());
crate::setup_visuals(ui.ctx()); });
}); },
}); );
}); });
}); });
} }
/// Setup window resize area. /// Setup window resize area.
fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) { fn resize_area_ui(&mut self, ui: &egui::Ui, direction: ResizeDirection) {
let mut rect = ui.max_rect(); let mut rect = ui.max_rect();
// Setup area id, cursor and area rect based on direction. // Setup area id, cursor and area rect based on direction.
let (id, cursor, rect) = match direction { let (id, cursor, rect) = match direction {
ResizeDirection::North => ("n", CursorIcon::ResizeNorth, { ResizeDirection::North => ("n", CursorIcon::ResizeNorth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0; rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN; rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0; rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect rect
}), }),
ResizeDirection::East => ("e", CursorIcon::ResizeEast, { ResizeDirection::East => ("e", CursorIcon::ResizeEast, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0; rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN; rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0; rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect rect
}), }),
ResizeDirection::South => ("s", CursorIcon::ResizeSouth, { ResizeDirection::South => ("s", CursorIcon::ResizeSouth, {
rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0; rect.min.x += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN; rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN;
rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0; rect.max.x -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect rect
}), }),
ResizeDirection::West => ("w", CursorIcon::ResizeWest, { ResizeDirection::West => ("w", CursorIcon::ResizeWest, {
rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0; rect.min.y += Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN; rect.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN;
rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0; rect.max.y -= Content::WINDOW_FRAME_MARGIN * 2.0;
rect rect
}), }),
ResizeDirection::NorthWest => ("nw", CursorIcon::ResizeNorthWest, { ResizeDirection::NorthWest => ("nw", CursorIcon::ResizeNorthWest, {
rect.max.y = rect.min.y + Content::WINDOW_FRAME_MARGIN * 2.0; 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.max.x = rect.max.y + Content::WINDOW_FRAME_MARGIN * 2.0;
rect rect
}), }),
ResizeDirection::NorthEast => ("ne", CursorIcon::ResizeNorthEast, { ResizeDirection::NorthEast => ("ne", CursorIcon::ResizeNorthEast, {
rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0; rect.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect.max.y = Content::WINDOW_FRAME_MARGIN * 2.0; rect.max.y = Content::WINDOW_FRAME_MARGIN * 2.0;
rect rect
}), }),
ResizeDirection::SouthEast => ("se", CursorIcon::ResizeSouthEast, { ResizeDirection::SouthEast => ("se", CursorIcon::ResizeSouthEast, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0; 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.min.x = rect.max.x - Content::WINDOW_FRAME_MARGIN * 2.0;
rect rect
}), }),
ResizeDirection::SouthWest => ("sw", CursorIcon::ResizeSouthWest, { ResizeDirection::SouthWest => ("sw", CursorIcon::ResizeSouthWest, {
rect.min.y = rect.max.y - Content::WINDOW_FRAME_MARGIN * 2.0; 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.max.x = rect.min.x + Content::WINDOW_FRAME_MARGIN * 2.0;
rect rect
}), }),
}; };
// Setup resize area. // Setup resize area.
let id = egui::Id::new("window_resize").with(id); let id = egui::Id::new("window_resize").with(id);
let sense = egui::Sense::drag(); let sense = egui::Sense::drag();
let area_resp = ui.interact(rect, id, sense).on_hover_cursor(cursor); let area_resp = ui.interact(rect, id, sense).on_hover_cursor(cursor);
if area_resp.dragged() { if area_resp.dragged() {
if self.resize_direction.is_none() { if self.resize_direction.is_none() {
self.resize_direction = Some(direction.clone()); self.resize_direction = Some(direction.clone());
ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)); ui.ctx()
} .send_viewport_cmd(ViewportCommand::BeginResize(direction));
} }
if area_resp.drag_stopped() { }
self.resize_direction = None; if area_resp.drag_stopped() {
} self.resize_direction = None;
} }
}
} }
/// To draw with egui`s eframe (for wgpu, glow backends and wasm target). /// To draw with egui`s eframe (for wgpu, glow backends and wasm target).
impl<Platform: PlatformCallbacks> eframe::App for App<Platform> { impl<Platform: PlatformCallbacks> eframe::App for App<Platform> {
fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) { fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) {
self.ui(ctx); ctx.plugin_or_default::<egui_async::EguiAsyncPlugin>();
} self.ui(ctx);
}
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
if View::is_desktop() { let os = egui::os::OperatingSystem::from_target_os();
let is_mac_os = OperatingSystem::from_target_os() == OperatingSystem::Mac; let is_win = os == egui::os::OperatingSystem::Windows;
if is_mac_os { let is_mac = os == egui::os::OperatingSystem::Mac;
Colors::fill().to_normalized_gamma_f32() if !View::is_desktop() || is_win || is_mac {
} else { return Colors::fill_lite().to_normalized_gamma_f32();
egui::Rgba::TRANSPARENT.to_array() }
} Colors::TRANSPARENT.to_normalized_gamma_f32()
} else { }
Colors::fill().to_normalized_gamma_f32()
}
}
} }
#[allow(dead_code)] #[allow(dead_code)]
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[no_mangle] #[unsafe(no_mangle)]
/// Handle Back key code event from Android. /// Handle Back key code event from Android.
pub extern "C" fn Java_mw_gri_android_MainActivity_onBack( pub extern "C" fn Java_mw_gri_android_MainActivity_onBack(
_env: jni::JNIEnv, _env: jni::JNIEnv,
_class: jni::objects::JObject, _class: jni::objects::JObject,
_activity: jni::objects::JObject, _activity: jni::objects::JObject,
) { ) {
BACK_BUTTON_PRESSED.store(true, Ordering::Relaxed); BACK_BUTTON_PRESSED.store(true, Ordering::Relaxed);
} }
+141 -157
View File
@@ -26,21 +26,31 @@ const SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(100);
const DARK_SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(170); const DARK_SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(170);
const GOLD: Color32 = Color32::from_rgb(255, 215, 0); const GOLD: Color32 = Color32::from_rgb(255, 215, 0);
const GOLD_DARK: Color32 = Color32::from_rgb(240, 203, 1);
const YELLOW: Color32 = Color32::from_rgb(254, 241, 2); const YELLOW: Color32 = Color32::from_rgb(254, 241, 2);
const YELLOW_DARK: Color32 = Color32::from_rgb(239, 229, 3); const YELLOW_DARK: Color32 = Color32::from_rgb(239, 229, 3);
const GREEN: Color32 = Color32::from_rgb(0, 0x64, 0); 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: 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: 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: Color32 = Color32::from_gray(244);
const FILL_DARK: Color32 = Color32::from_gray(24); const FILL_DARK: Color32 = Color32::from_gray(26);
const FILL_DEEP: Color32 = Color32::from_gray(238); const FILL_DEEP_DARK: Color32 = Color32::from_gray(32);
const FILL_DEEP_DARK: Color32 = Color32::from_gray(18);
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: Color32 = Color32::from_gray(80);
const TEXT_DARK: Color32 = Color32::from_gray(185); const TEXT_DARK: Color32 = Color32::from_gray(185);
@@ -54,13 +64,9 @@ const TEXT_BUTTON_DARK: Color32 = Color32::from_gray(195);
const TITLE: Color32 = Color32::from_gray(60); const TITLE: Color32 = Color32::from_gray(60);
const TITLE_DARK: Color32 = Color32::from_gray(205); const TITLE_DARK: Color32 = Color32::from_gray(205);
const BUTTON: Color32 = Color32::from_gray(249);
const BUTTON_DARK: Color32 = Color32::from_gray(16);
const GRAY: Color32 = Color32::from_gray(120); const GRAY: Color32 = Color32::from_gray(120);
const GRAY_DARK: Color32 = Color32::from_gray(145); const GRAY_DARK: Color32 = Color32::from_gray(145);
const STROKE: Color32 = Color32::from_gray(200);
const STROKE_DARK: Color32 = Color32::from_gray(50); const STROKE_DARK: Color32 = Color32::from_gray(50);
const INACTIVE_TEXT: Color32 = Color32::from_gray(150); const INACTIVE_TEXT: Color32 = Color32::from_gray(150);
@@ -77,177 +83,155 @@ const ITEM_HOVER_DARK: Color32 = Color32::from_gray(48);
/// Check if dark theme should be used. /// Check if dark theme should be used.
fn use_dark() -> bool { fn use_dark() -> bool {
AppConfig::dark_theme().unwrap_or(false) AppConfig::dark_theme().unwrap_or(false)
} }
impl Colors { impl Colors {
pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0); 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 fn white_or_black(black_in_white: bool) -> Color32 { pub fn white_or_black(black_in_white: bool) -> Color32 {
if use_dark() { if use_dark() {
if black_in_white { if black_in_white { WHITE } else { BLACK }
WHITE } else {
} else { if black_in_white { BLACK } else { WHITE }
BLACK }
} }
} else {
if black_in_white {
BLACK
} else {
WHITE
}
}
}
pub fn semi_transparent() -> Color32 { pub fn semi_transparent() -> Color32 {
if use_dark() { if use_dark() {
DARK_SEMI_TRANSPARENT DARK_SEMI_TRANSPARENT
} else { } else {
SEMI_TRANSPARENT SEMI_TRANSPARENT
} }
} }
pub fn gold() -> Color32 { pub fn gold() -> Color32 {
if use_dark() { if use_dark() {
GOLD.gamma_multiply(0.9) GOLD.gamma_multiply(0.9)
} else { } else {
GOLD GOLD
} }
} }
pub fn yellow() -> Color32 { pub fn gold_dark() -> Color32 {
YELLOW if use_dark() {
} GOLD_DARK.gamma_multiply(0.9)
} else {
GOLD_DARK
}
}
pub fn yellow_dark() -> Color32 { pub fn yellow() -> Color32 {
YELLOW_DARK YELLOW
} }
pub fn green() -> Color32 { pub fn yellow_dark() -> Color32 {
if use_dark() { YELLOW_DARK
GREEN.gamma_multiply(1.3) }
} else {
GREEN
}
}
pub fn red() -> Color32 { pub fn green() -> Color32 {
if use_dark() { if use_dark() { GREEN_DARK } else { GREEN }
RED.gamma_multiply(1.3) }
} else {
RED
}
}
pub fn blue() -> Color32 { pub fn red() -> Color32 {
if use_dark() { if use_dark() { RED_DARK } else { RED }
BLUE.gamma_multiply(1.3) }
} else {
BLUE
}
}
pub fn fill() -> Color32 { pub fn blue() -> Color32 {
if use_dark() { if use_dark() { BLUE_DARK } else { BLUE }
FILL_DARK }
} else {
FILL
}
}
pub fn fill_deep() -> Color32 { pub fn fill() -> Color32 {
if use_dark() { if use_dark() { FILL_DARK } else { FILL }
FILL_DEEP_DARK }
} else {
FILL_DEEP
}
}
pub fn checkbox() -> Color32 { pub fn fill_deep() -> Color32 {
if use_dark() { if use_dark() {
CHECKBOX_DARK FILL_DEEP_DARK
} else { } else {
CHECKBOX Self::FILL_DEEP
} }
} }
pub fn text(always_light: bool) -> Color32 { pub fn fill_lite() -> Color32 {
if use_dark() && !always_light { if use_dark() {
TEXT_DARK FILL_LITE_DARK
} else { } else {
TEXT FILL_LITE
} }
} }
pub fn text_button() -> Color32 { pub fn checkbox() -> Color32 {
if use_dark() { if use_dark() { CHECKBOX_DARK } else { CHECKBOX }
TEXT_BUTTON_DARK }
} else {
TEXT_BUTTON
}
}
pub fn title(always_light: bool) -> Color32 { pub fn text(always_light: bool) -> Color32 {
if use_dark() && !always_light { if use_dark() && !always_light {
TITLE_DARK TEXT_DARK
} else { } else {
TITLE TEXT
} }
} }
pub fn button() -> Color32 { pub fn text_button() -> Color32 {
if use_dark() { if use_dark() {
BUTTON_DARK TEXT_BUTTON_DARK
} else { } else {
BUTTON TEXT_BUTTON
} }
} }
pub fn gray() -> Color32 { pub fn title(always_light: bool) -> Color32 {
if use_dark() { if use_dark() && !always_light {
GRAY_DARK TITLE_DARK
} else { } else {
GRAY TITLE
} }
} }
pub fn stroke() -> Color32 { pub fn gray() -> Color32 {
if use_dark() { if use_dark() { GRAY_DARK } else { GRAY }
STROKE_DARK }
} else {
STROKE
}
}
pub fn inactive_text() -> Color32 { pub fn stroke() -> Color32 {
if use_dark() { if use_dark() {
INACTIVE_TEXT_DARK STROKE_DARK
} else { } else {
INACTIVE_TEXT Self::STROKE
} }
} }
pub fn item_button() -> Color32 { pub fn inactive_text() -> Color32 {
if use_dark() { if use_dark() {
ITEM_BUTTON_DARK INACTIVE_TEXT_DARK
} else { } else {
ITEM_BUTTON INACTIVE_TEXT
} }
} }
pub fn item_stroke() -> Color32 { pub fn item_button_text() -> Color32 {
if use_dark() { if use_dark() {
ITEM_STROKE_DARK ITEM_BUTTON_DARK
} else { } else {
ITEM_STROKE ITEM_BUTTON
} }
} }
pub fn item_hover() -> Color32 { pub fn item_stroke() -> Color32 {
if use_dark() { if use_dark() {
ITEM_HOVER_DARK ITEM_STROKE_DARK
} else { } else {
ITEM_HOVER ITEM_STROKE
} }
} }
}
pub fn item_hover() -> Color32 {
if use_dark() {
ITEM_HOVER_DARK
} else {
ITEM_HOVER
}
}
}
+1 -2
View File
@@ -12,13 +12,12 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
mod app; mod app;
pub use app::App; pub use app::App;
mod colors; mod colors;
pub use colors::Colors; pub use colors::Colors;
pub mod icons;
pub mod platform; pub mod platform;
pub mod views; pub mod views;
pub mod icons;
+181 -149
View File
@@ -12,14 +12,14 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use lazy_static::lazy_static;
use parking_lot::RwLock;
use std::env; use std::env;
use std::ffi::OsString; use std::ffi::OsString;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use lazy_static::lazy_static;
use std::sync::Arc; use std::sync::Arc;
use parking_lot::RwLock;
use jni::JNIEnv; use jni::JNIEnv;
use jni::objects::{JByteArray, JObject, JString, JValue}; use jni::objects::{JByteArray, JObject, JString, JValue};
@@ -30,185 +30,217 @@ use crate::gui::platform::PlatformCallbacks;
/// Android platform implementation. /// Android platform implementation.
#[derive(Clone)] #[derive(Clone)]
pub struct Android { pub struct Android {
android_app: AndroidApp, /// Android related state.
android_app: AndroidApp,
/// Context to repaint content and handle viewport commands.
ctx: Arc<RwLock<Option<egui::Context>>>,
} }
impl Android { impl Android {
/// Create new Android platform instance from provided [`AndroidApp`]. /// Create new Android platform instance from provided [`AndroidApp`].
pub fn new(app: AndroidApp) -> Self { pub fn new(app: AndroidApp) -> Self {
Self { Self {
android_app: app, android_app: app,
} ctx: Arc::new(RwLock::new(None)),
} }
}
/// Call Android Activity method with JNI. /// Call Android Activity method with JNI.
pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option<jni::sys::jvalue> { 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 vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap(); let mut env = vm.attach_current_thread().unwrap();
let activity = unsafe { let activity =
JObject::from_raw(self.android_app.activity_as_ptr() as jni::sys::jobject) 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) {
if let Ok(result) = env.call_method(activity, name, s, a) { return Some(result.as_jni().clone());
return Some(result.as_jni().clone()); }
} None
None }
}
} }
impl PlatformCallbacks for Android { impl PlatformCallbacks for Android {
fn show_keyboard(&self) { fn set_context(&mut self, ctx: &egui::Context) {
// Disable NDK soft input show call before fix for egui. let mut w_ctx = self.ctx.write();
// self.android_app.show_soft_input(false); *w_ctx = Some(ctx.clone());
}
self.call_java_method("showKeyboard", "()V", &[]).unwrap(); fn exit(&self) {
} let _ = self.call_java_method("exit", "()V", &[]);
}
fn hide_keyboard(&self) { fn copy_string_to_buffer(&self, data: String) {
// Disable NDK soft input hide call before fix for egui. let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
// self.android_app.hide_soft_input(false); let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(data).unwrap();
let _ = self.call_java_method(
"copyText",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))],
);
}
self.call_java_method("hideKeyboard", "()V", &[]).unwrap(); fn get_string_from_buffer(&self) -> String {
} let result = self
.call_java_method("pasteText", "()Ljava/lang/String;", &[])
.unwrap();
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let mut env = vm.attach_current_thread().unwrap();
let j_object: jni::sys::jobject = unsafe { result.l };
let paste_data: String = unsafe {
env.get_string(JString::from(JObject::from_raw(j_object)).as_ref())
.unwrap()
.into()
};
paste_data
}
fn copy_string_to_buffer(&self, data: String) { fn start_camera(&self) {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); // Clear image.
let env = vm.attach_current_thread().unwrap(); let mut w_image = LAST_CAMERA_IMAGE.write();
let arg_value = env.new_string(data).unwrap(); *w_image = None;
self.call_java_method("copyText", // Start camera.
"(Ljava/lang/String;)V", let _ = self.call_java_method("startCamera", "()V", &[]);
&[JValue::Object(&JObject::from(arg_value))]).unwrap(); }
}
fn get_string_from_buffer(&self) -> String { fn stop_camera(&self) {
let result = self.call_java_method("pasteText", "()Ljava/lang/String;", &[]).unwrap(); // Stop camera.
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); let _ = self.call_java_method("stopCamera", "()V", &[]);
let mut env = vm.attach_current_thread().unwrap(); // Clear image.
let j_object: jni::sys::jobject = unsafe { result.l }; let mut w_image = LAST_CAMERA_IMAGE.write();
let paste_data: String = unsafe { *w_image = None;
env.get_string(JString::from(JObject::from_raw(j_object)).as_ref()).unwrap().into() }
};
paste_data
}
fn start_camera(&self) { fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
// Clear image. let r_image = LAST_CAMERA_IMAGE.read();
let mut w_image = LAST_CAMERA_IMAGE.write(); if r_image.is_some() {
*w_image = None; return Some(r_image.clone().unwrap());
// Start camera. }
self.call_java_method("startCamera", "()V", &[]).unwrap(); None
} }
fn stop_camera(&self) { fn can_switch_camera(&self) -> bool {
// Stop camera. if let Some(res) = self.call_java_method("camerasAmount", "()I", &[]) {
self.call_java_method("stopCamera", "()V", &[]).unwrap(); let amount = unsafe { res.i };
// Clear image. return amount > 1;
let mut w_image = LAST_CAMERA_IMAGE.write(); }
*w_image = None; false
} }
fn camera_image(&self) -> Option<(Vec<u8>, u32)> { fn switch_camera(&self) {
let r_image = LAST_CAMERA_IMAGE.read(); let _ = self.call_java_method("switchCamera", "()V", &[]);
if r_image.is_some() { }
return Some(r_image.clone().unwrap());
}
None
}
fn can_switch_camera(&self) -> bool { fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
let result = self.call_java_method("camerasAmount", "()I", &[]).unwrap(); let default_cache = OsString::from(dirs::cache_dir().unwrap());
let amount = unsafe { result.i }; let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache));
amount > 1 // File path for Android provider.
} file.push("share");
if !file.exists() {
std::fs::create_dir(file.clone())?;
}
file.push(name);
if file.exists() {
std::fs::remove_file(file.clone())?;
}
let mut image = File::create_new(file.clone())?;
image.write_all(data.as_slice())?;
image.sync_all()?;
// Call share modal at system.
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(file.to_str().unwrap()).unwrap();
let _ = self.call_java_method(
"shareData",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))],
);
Ok(())
}
fn switch_camera(&self) { fn pick_file(&self) -> Option<String> {
self.call_java_method("switchCamera", "()V", &[]).unwrap(); // 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_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> { fn pick_folder(&self) -> Option<String> {
// Create file at cache dir. // Clear previous result.
let default_cache = OsString::from(dirs::cache_dir().unwrap()); let mut w_path = PICKED_FILE_PATH.write();
let mut cache = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache)); *w_path = None;
cache.push("images"); // Launch file picker.
std::fs::create_dir_all(cache.to_str().unwrap())?; let _ = self.call_java_method("pickFolder", "()V", &[]);
cache.push(name); // Return empty string to identify async pick.
let mut image = File::create_new(cache.clone()).unwrap(); Some("".to_string())
image.write_all(data.as_slice()).unwrap(); }
image.sync_all().unwrap();
// Call share modal at system.
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let arg_value = env.new_string(cache.to_str().unwrap()).unwrap();
self.call_java_method("shareImage",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))]).unwrap();
Ok(())
}
fn pick_file(&self) -> Option<String> { fn picked_file(&self) -> Option<String> {
// Clear previous result. let has_file = {
let mut w_path = PICKED_FILE_PATH.write(); let r_path = PICKED_FILE_PATH.read();
*w_path = None; r_path.is_some()
// Launch file picker. };
let _ = self.call_java_method("pickFile", "()V", &[]).unwrap(); if has_file {
// Return empty string to identify async pick. let mut w_path = PICKED_FILE_PATH.write();
Some("".to_string()) let path = Some(w_path.clone().unwrap());
} *w_path = None;
return path;
}
None
}
fn picked_file(&self) -> Option<String> { fn request_user_attention(&self) {}
let has_file = {
let r_path = PICKED_FILE_PATH.read(); fn user_attention_required(&self) -> bool {
r_path.is_some() false
}; }
if has_file {
let mut w_path = PICKED_FILE_PATH.write(); fn clear_user_attention(&self) {}
let path = Some(w_path.clone().unwrap());
*w_path = None;
return path
}
None
}
} }
lazy_static! { lazy_static! {
/// Last image data from camera. /// Last image data from camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None)); static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
/// Picked file path. /// Picked file path.
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None)); 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. /// Callback from Java code with last entered character from soft keyboard.
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[no_mangle] #[unsafe(no_mangle)]
pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage( pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage(
env: JNIEnv, env: JNIEnv,
_class: JObject, _class: JObject,
buff: jni::sys::jbyteArray, buff: jni::sys::jbyteArray,
rotation: jni::sys::jint, rotation: jni::sys::jint,
) { ) {
let arr = unsafe { JByteArray::from_raw(buff) }; let arr = unsafe { JByteArray::from_raw(buff) };
let image : Vec<u8> = env.convert_byte_array(arr).unwrap(); let image: Vec<u8> = env.convert_byte_array(arr).unwrap();
let mut w_image = LAST_CAMERA_IMAGE.write(); let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((image, rotation as u32)); *w_image = Some((image, rotation as u32));
} }
/// Callback from Java code with picked file path. /// Callback from Java code with picked file path.
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[no_mangle] #[unsafe(no_mangle)]
pub extern "C" fn Java_mw_gri_android_MainActivity_onFilePick( pub extern "C" fn Java_mw_gri_android_MainActivity_onFilePick(
_env: JNIEnv, _env: JNIEnv,
_class: JObject, _class: JObject,
char: jni::sys::jstring char: jni::sys::jstring,
) { ) {
use std::ops::Add; use std::ops::Add;
unsafe { unsafe {
let j_obj = JString::from_raw(char); let j_obj = JString::from_raw(char);
let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap(); let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap();
match j_str.to_str() { match j_str.to_str() {
Ok(str) => { Ok(str) => {
let mut w_path = PICKED_FILE_PATH.write(); let mut w_path = PICKED_FILE_PATH.write();
*w_path = Some(w_path.clone().unwrap_or("".to_string()).add(str)); *w_path = Some(w_path.clone().unwrap_or("".to_string()).add(str));
} }
Err(_) => {} Err(_) => {}
} }
} }
} }
+290 -171
View File
@@ -12,197 +12,316 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use std::fs::File; use egui::{UserAttentionType, ViewportCommand, WindowLevel};
use std::io:: Write;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use rfd::FileDialog; use rfd::FileDialog;
use std::fs::File;
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
/// Desktop platform related actions. /// Desktop platform related actions.
#[derive(Clone)] #[derive(Clone)]
pub struct Desktop { pub struct Desktop {
/// Flag to check if camera stop is needed. /// Context to repaint content and handle viewport commands.
stop_camera: Arc<AtomicBool>, ctx: Arc<RwLock<Option<egui::Context>>>,
}
impl Default for Desktop { /// Cameras amount.
fn default() -> Self { cameras_amount: Arc<AtomicUsize>,
Self { /// Camera index.
stop_camera: Arc::new(AtomicBool::new(false)), camera_index: Arc<AtomicUsize>,
} /// Flag to check if camera stop is needed.
} stop_camera: Arc<AtomicBool>,
}
impl PlatformCallbacks for Desktop { /// Flag to check if attention required after window focusing.
fn show_keyboard(&self) {} attention_required: Arc<AtomicBool>,
fn hide_keyboard(&self) {}
fn copy_string_to_buffer(&self, data: String) {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.set_text(data).unwrap();
}
fn get_string_from_buffer(&self) -> String {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.get_text().unwrap_or("".to_string())
}
fn start_camera(&self) {
// Clear image.
{
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
}
// Setup stop camera flag.
let stop_camera = self.stop_camera.clone();
stop_camera.store(false, Ordering::Relaxed);
// Capture images at separate thread.
thread::spawn(move || {
Self::start_camera_capture(stop_camera);
});
}
fn stop_camera(&self) {
// Stop camera.
self.stop_camera.store(true, Ordering::Relaxed);
}
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
let r_image = LAST_CAMERA_IMAGE.read();
if r_image.is_some() {
return r_image.clone();
}
None
}
fn can_switch_camera(&self) -> bool {
false
}
fn switch_camera(&self) {
return;
}
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
let folder = FileDialog::new()
.set_title(t!("share"))
.set_directory(dirs::home_dir().unwrap())
.set_file_name(name.clone())
.save_file();
if let Some(folder) = folder {
let mut image = File::create(folder)?;
image.write_all(data.as_slice())?;
image.sync_all()?;
}
Ok(())
}
fn pick_file(&self) -> Option<String> {
let file = FileDialog::new()
.set_title(t!("choose_file"))
.set_directory(dirs::home_dir().unwrap())
.pick_file();
if let Some(file) = file {
return Some(file.to_str().unwrap_or_default().to_string());
}
None
}
fn picked_file(&self) -> Option<String> {
None
}
} }
impl Desktop { impl Desktop {
#[allow(dead_code)] pub fn new() -> Self {
#[cfg(target_os = "windows")] Self {
fn start_camera_capture(stop_camera: Arc<AtomicBool>) { ctx: Arc::new(RwLock::new(None)),
use nokhwa::Camera; cameras_amount: Arc::new(AtomicUsize::new(0)),
use nokhwa::pixel_format::RgbFormat; camera_index: Arc::new(AtomicUsize::new(0)),
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType}; stop_camera: Arc::new(AtomicBool::new(false)),
let index = CameraIndex::Index(0); attention_required: Arc::new(AtomicBool::new(false)),
let requested = RequestedFormat::new::<RgbFormat>( }
RequestedFormatType::AbsoluteHighestFrameRate }
);
// Create and open camera.
let mut camera = Camera::new(index, requested).unwrap();
if let Ok(_) = camera.open_stream() {
loop {
// Stop if camera was stopped.
if stop_camera.load(Ordering::Relaxed) {
stop_camera.store(false, Ordering::Relaxed);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
// Get a frame.
if let Ok(frame) = camera.frame() {
// Save image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((frame.buffer().to_vec(), 0));
} else {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
}
camera.stop_stream().unwrap();
};
}
#[allow(dead_code)] // #[allow(dead_code)]
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "macos"))]
fn start_camera_capture(stop_camera: Arc<AtomicBool>) { fn start_camera_capture(
use eye::hal::{traits::{Context, Device, Stream}, PlatformContext}; cameras_amount: Arc<AtomicUsize>,
use image::ImageEncoder; camera_index: Arc<AtomicUsize>,
stop_camera: Arc<AtomicBool>,
) {
use nokhwa::Camera;
use nokhwa::pixel_format::RgbFormat;
use nokhwa::utils::ApiBackend;
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
let ctx = PlatformContext::default(); let devices = nokhwa::query(ApiBackend::Auto).unwrap();
let devices = ctx.devices().unwrap(); cameras_amount.store(devices.len(), Ordering::Relaxed);
let dev = ctx.open_device(&devices[0].uri).unwrap(); let index = camera_index.load(Ordering::Relaxed);
if devices.is_empty() || index >= devices.len() {
return;
}
let streams = dev.streams().unwrap(); thread::spawn(move || {
let stream_desc = streams[0].clone(); let index = CameraIndex::Index(camera_index.load(Ordering::Relaxed) as u32);
let w = stream_desc.width; let requested =
let h = stream_desc.height; 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();
};
}
});
}
let mut stream = dev.start_stream(&stream_desc).unwrap(); #[allow(dead_code)]
#[cfg(target_os = "macos")]
fn start_camera_capture(
cameras_amount: Arc<AtomicUsize>,
camera_index: Arc<AtomicUsize>,
stop_camera: Arc<AtomicBool>,
) {
use nokhwa::CallbackCamera;
use nokhwa::nokhwa_initialize;
use nokhwa::pixel_format::RgbFormat;
use nokhwa::query;
use nokhwa::utils::ApiBackend;
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
loop { // Ask permission to open camera.
// Stop if camera was stopped. nokhwa_initialize(|_| {});
if stop_camera.load(Ordering::Relaxed) {
stop_camera.store(false, Ordering::Relaxed); thread::spawn(move || {
// Clear image. let cameras = query(ApiBackend::Auto).unwrap();
let mut w_image = LAST_CAMERA_IMAGE.write(); cameras_amount.store(cameras.len(), Ordering::Relaxed);
*w_image = None; let index = camera_index.load(Ordering::Relaxed);
break; if cameras.is_empty() || index >= cameras.len() {
} return;
// Get a frame. }
let frame = stream.next().expect("Stream is dead").expect("Failed to capture a frame"); // Start camera.
let mut out = vec![]; let camera_index = CameraIndex::Index(camera_index.load(Ordering::Relaxed) as u32);
if let Some(buf) = image::ImageBuffer::<image::Rgb<u8>, &[u8]>::from_raw(w, h, &frame) { let camera_callback = CallbackCamera::new(
image::codecs::jpeg::JpegEncoder::new(&mut out) camera_index,
.write_image(buf.as_raw(), w, h, image::ExtendedColorType::Rgb8).unwrap(); RequestedFormat::new::<RgbFormat>(RequestedFormatType::AbsoluteHighestFrameRate),
} else { |_| {},
out = frame.to_vec(); );
} if let Ok(mut cb) = camera_callback {
// Save image. if cb.open_stream().is_ok() {
let mut w_image = LAST_CAMERA_IMAGE.write(); loop {
*w_image = Some((out, 0)); // Stop if camera was stopped.
} if stop_camera.load(Ordering::Relaxed) {
} stop_camera.store(false, Ordering::Relaxed);
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
// Get image from camera.
if let Ok(frame) = cb.poll_frame() {
let image = frame.decode_image::<RgbFormat>().unwrap();
let mut bytes: Vec<u8> = Vec::new();
let format = image::ImageFormat::Jpeg;
// Convert image to Jpeg format.
image
.write_to(&mut std::io::Cursor::new(&mut bytes), format)
.unwrap();
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = Some((bytes, 0));
} else {
// Clear image.
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
break;
}
}
}
}
});
}
}
impl PlatformCallbacks for Desktop {
fn set_context(&mut self, ctx: &egui::Context) {
let mut w_ctx = self.ctx.write();
*w_ctx = Some(ctx.clone());
}
fn exit(&self) {
let r_ctx = self.ctx.read();
if r_ctx.is_some() {
let ctx = r_ctx.as_ref().unwrap();
ctx.send_viewport_cmd(ViewportCommand::Close);
}
}
fn copy_string_to_buffer(&self, data: String) {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.set_text(data).unwrap();
}
fn get_string_from_buffer(&self) -> String {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.get_text().unwrap_or("".to_string())
}
fn start_camera(&self) {
// Clear image.
{
let mut w_image = LAST_CAMERA_IMAGE.write();
*w_image = None;
}
// Setup stop camera flag.
let stop_camera = self.stop_camera.clone();
stop_camera.store(false, Ordering::Relaxed);
Self::start_camera_capture(
self.cameras_amount.clone(),
self.camera_index.clone(),
stop_camera,
);
}
fn stop_camera(&self) {
// Stop camera.
self.stop_camera.store(true, Ordering::Relaxed);
}
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
let r_image = LAST_CAMERA_IMAGE.read();
if r_image.is_some() {
return r_image.clone();
}
None
}
fn can_switch_camera(&self) -> bool {
let amount = self.cameras_amount.load(Ordering::Relaxed);
amount > 1
}
fn switch_camera(&self) {
self.stop_camera();
let index = self.camera_index.load(Ordering::Relaxed);
let amount = self.cameras_amount.load(Ordering::Relaxed);
if index == amount - 1 {
self.camera_index.store(0, Ordering::Relaxed);
} else {
self.camera_index.store(index + 1, Ordering::Relaxed);
}
self.start_camera();
}
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
let folder = FileDialog::new()
.set_title(t!("share"))
.set_directory(dirs::home_dir().unwrap())
.set_file_name(name.clone())
.save_file();
if let Some(folder) = folder {
let mut image = File::create(folder)?;
image.write_all(data.as_slice())?;
image.sync_all()?;
}
Ok(())
}
fn pick_file(&self) -> Option<String> {
let file = FileDialog::new()
.set_title(t!("choose_file"))
.set_directory(dirs::home_dir().unwrap())
.pick_file();
if let Some(file) = file {
return Some(file.to_str().unwrap_or_default().to_string());
}
None
}
fn pick_folder(&self) -> Option<String> {
let file = FileDialog::new()
.set_title(t!("choose_folder"))
.set_directory(dirs::home_dir().unwrap())
.pick_folder();
if let Some(file) = file {
return Some(file.to_str().unwrap_or_default().to_string());
}
None
}
fn picked_file(&self) -> Option<String> {
None
}
fn request_user_attention(&self) {
let r_ctx = self.ctx.read();
if r_ctx.is_some() {
let ctx = r_ctx.as_ref().unwrap();
// Request attention on taskbar.
ctx.send_viewport_cmd(ViewportCommand::RequestUserAttention(
UserAttentionType::Informational,
));
// Un-minimize window.
if ctx.input(|i| i.viewport().minimized.unwrap_or(false)) {
ctx.send_viewport_cmd(ViewportCommand::Minimized(false));
}
// Focus to window.
if !ctx.input(|i| i.viewport().focused.unwrap_or(false)) {
ctx.send_viewport_cmd(ViewportCommand::WindowLevel(WindowLevel::AlwaysOnTop));
ctx.send_viewport_cmd(ViewportCommand::Focus);
}
ctx.request_repaint();
}
self.attention_required.store(true, Ordering::Relaxed);
}
fn user_attention_required(&self) -> bool {
self.attention_required.load(Ordering::Relaxed)
}
fn clear_user_attention(&self) {
let r_ctx = self.ctx.read();
if r_ctx.is_some() {
let ctx = r_ctx.as_ref().unwrap();
ctx.send_viewport_cmd(ViewportCommand::RequestUserAttention(
UserAttentionType::Reset,
));
ctx.send_viewport_cmd(ViewportCommand::WindowLevel(WindowLevel::Normal));
}
self.attention_required.store(false, Ordering::Relaxed);
}
} }
lazy_static! { lazy_static! {
/// Last captured image from started camera. /// Last captured image from started camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None)); static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
} }
+17 -13
View File
@@ -22,16 +22,20 @@ pub mod platform;
pub mod platform; pub mod platform;
pub trait PlatformCallbacks { pub trait PlatformCallbacks {
fn show_keyboard(&self); fn set_context(&mut self, ctx: &egui::Context);
fn hide_keyboard(&self); fn exit(&self);
fn copy_string_to_buffer(&self, data: String); fn copy_string_to_buffer(&self, data: String);
fn get_string_from_buffer(&self) -> String; fn get_string_from_buffer(&self) -> String;
fn start_camera(&self); fn start_camera(&self);
fn stop_camera(&self); fn stop_camera(&self);
fn camera_image(&self) -> Option<(Vec<u8>, u32)>; fn camera_image(&self) -> Option<(Vec<u8>, u32)>;
fn can_switch_camera(&self) -> bool; fn can_switch_camera(&self) -> bool;
fn switch_camera(&self); fn switch_camera(&self);
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>; fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
fn pick_file(&self) -> Option<String>; fn pick_file(&self) -> Option<String>;
fn picked_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);
}
+436 -393
View File
@@ -12,432 +12,475 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use std::sync::Arc;
use parking_lot::RwLock;
use std::thread;
use eframe::emath::Align;
use egui::load::SizedTexture; use egui::load::SizedTexture;
use egui::{Layout, Pos2, Rect, RichText, TextureOptions, Widget}; use egui::{Pos2, Rect, RichText, TextureOptions, UiBuilder, Widget};
use image::{DynamicImage, EncodableLayout, ImageFormat}; use grin_keychain::mnemonic::WORDS;
use grin_util::ZeroingString; use grin_util::ZeroingString;
use grin_wallet_libwallet::SlatepackAddress; use grin_wallet_libwallet::SlatepackAddress;
use grin_keychain::mnemonic::WORDS; use image::{DynamicImage, EncodableLayout};
use parking_lot::RwLock;
use std::sync::Arc;
use std::thread;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::CAMERA_ROTATE; use crate::gui::icons::CAMERA_ROTATE;
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::{QrScanResult, QrScanState}; use crate::gui::views::types::{QrScanResult, QrScanState};
use crate::gui::views::View; use crate::gui::views::{Modal, View};
use crate::wallet::types::PhraseSize;
use crate::wallet::WalletUtils; use crate::wallet::WalletUtils;
use crate::wallet::types::PhraseSize;
/// Camera QR code scanner. /// Camera QR code scanner.
pub struct CameraContent { pub struct CameraContent {
/// QR code scanning progress and result. /// QR code scanning progress and result.
qr_scan_state: Arc<RwLock<QrScanState>>, qr_scan_state: Arc<RwLock<QrScanState>>,
/// Uniform Resources URIs collected from QR code scanning.
/// Uniform Resources URIs collected from QR code scanning. ur_data: Arc<RwLock<Option<(Vec<String>, usize)>>>,
ur_data: Arc<RwLock<Option<(Vec<String>, usize)>>>,
} }
impl Default for CameraContent { impl Default for CameraContent {
fn default() -> Self { fn default() -> Self {
Self { Self {
qr_scan_state: Arc::new(RwLock::new(QrScanState::default())), qr_scan_state: Arc::new(RwLock::new(QrScanState::default())),
ur_data: Arc::new(RwLock::new(None)), ur_data: Arc::new(RwLock::new(None)),
} }
} }
} }
impl CameraContent { impl CameraContent {
/// Draw camera content. /// Draw camera content.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
// Draw last image from camera or loader. let rect = if let Some(img_data) = cb.camera_image() {
if let Some(img_data) = cb.camera_image() { if let Ok(img) = image::load_from_memory(&*img_data.0) {
// Load image to draw. // Process image to find QR code.
if let Ok(mut img) = self.scan_qr(&img);
image::load_from_memory_with_format(&*img_data.0, ImageFormat::Jpeg) {
// Process image to find QR code.
self.scan_qr(&img);
// Setup image rotation.
img = match img_data.1 {
90 => img.rotate90(),
180 => img.rotate180(),
270 => img.rotate270(),
_ => img
};
// Convert to ColorImage to add at content.
let color_img = match &img {
DynamicImage::ImageRgb8(image) => {
egui::ColorImage::from_rgb(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
)
},
other => {
let image = other.to_rgba8();
egui::ColorImage::from_rgba_unmultiplied(
[image.width() as usize, image.height() as usize],
image.as_bytes(),
)
},
};
// Create image texture.
let texture = ui.ctx().load_texture("camera_image",
color_img.clone(),
TextureOptions::default());
let img_size = egui::emath::vec2(color_img.width() as f32,
color_img.height() as f32);
let sized_img = SizedTexture::new(texture.id(), img_size);
// Add image to content.
ui.vertical_centered(|ui| {
egui::Image::from_texture(sized_img)
// Setup to crop image at square.
.uv(Rect::from([
Pos2::new(1.0 - (img_size.y / img_size.x), 0.0),
Pos2::new(1.0, 1.0)
]))
.max_height(ui.available_width())
.maintain_aspect_ratio(false)
.shrink_to_fit()
.ui(ui);
});
// Show UR scan progress. // Draw image.
let show_ur_progress = { let img_rect = self.image_ui(ui, img, img_data.1);
self.ur_data.clone().read().is_some()
};
let ur_progress = self.ur_progress();
if show_ur_progress && ur_progress != 0 {
ui.add_space(-52.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(format!("{}%", ur_progress))
.size(16.0)
.color(Colors::yellow()));
});
}
// Show button to switch cameras. img_rect
if cb.can_switch_camera() { } else {
ui.add_space(-52.0); self.loading_ui(ui)
let mut size = ui.available_size(); }
size.y = 48.0; } else {
ui.allocate_ui_with_layout(size, Layout::right_to_left(Align::Max), |ui| { self.loading_ui(ui)
ui.add_space(4.0); };
View::button(ui, CAMERA_ROTATE.to_string(), Colors::white_or_black(false), || {
cb.switch_camera();
});
});
}
} else {
self.loading_content_ui(ui);
}
} else {
self.loading_content_ui(ui);
}
// Request redraw. // Show UR scan progress.
ui.ctx().request_repaint(); self.ur_progress_ui(ui, &rect);
}
/// Draw camera loading progress content. // Show button to switch cameras.
fn loading_content_ui(&self, ui: &mut egui::Ui) { if cb.can_switch_camera() {
let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0; let r = {
ui.vertical_centered(|ui| { let mut r = rect.clone();
ui.add_space(space); r.min.y = r.max.y - 52.0;
View::big_loading_spinner(ui); r.min.x = r.max.x - 52.0;
ui.add_space(space); r
}); };
} ui.scope_builder(UiBuilder::new().max_rect(r), |ui| {
let rotate_img = CAMERA_ROTATE.to_string();
View::button(ui, rotate_img, Colors::white_or_black(false), || {
cb.switch_camera();
});
});
}
ui.add_space(6.0);
ui.ctx().request_repaint();
}
/// Check if image is processing to find QR code. /// Draw modal camera content.
fn image_processing(&self) -> bool { pub fn modal_ui(
let r_scan = self.qr_scan_state.read(); &mut self,
r_scan.image_processing ui: &mut egui::Ui,
} cb: &dyn PlatformCallbacks,
mut on_result: impl FnMut(Option<QrScanResult>),
) {
if let Some(result) = self.qr_scan_result() {
on_result(Some(result));
} else {
ui.add_space(6.0);
self.ui(ui, cb);
ui.add_space(6.0);
/// Get UR scanning progress in percents. // Setup spacing between buttons.
fn ur_progress(&self) -> i32 { ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// 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. // Show buttons to close modal or come back to sending input.
fn scan_qr(&self, image_data: &DynamicImage) { ui.columns(2, |cols| {
// Do not scan when another image is processing. cols[0].vertical_centered_justified(|ui| {
if self.image_processing() { View::button(ui, t!("close"), Colors::white_or_black(false), || {
return; cb.stop_camera();
} on_result(None);
// Setup scanning flag. Modal::close();
{ });
let mut w_scan = self.qr_scan_state.write(); });
w_scan.image_processing = true; cols[1].vertical_centered_justified(|ui| {
} View::button(ui, t!("back"), Colors::white_or_black(false), || {
on_result(None);
});
});
});
ui.add_space(6.0);
}
}
let image_data = image_data.clone(); /// Draw camera image.
let qr_scan_state = self.qr_scan_state.clone(); fn image_ui(&mut self, ui: &mut egui::Ui, mut img: DynamicImage, rotation: u32) -> Rect {
let ur_data = self.ur_data.clone(); // 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
}
let on_scan = async move { /// Draw animated QR code scanning progress.
// Prepare image data. fn ur_progress_ui(&self, ui: &mut egui::Ui, rect: &Rect) {
let img = image_data.to_luma8(); let show_ur_progress = { self.ur_data.as_ref().read().is_some() };
let mut img: rqrr::PreparedImage<image::GrayImage> if show_ur_progress {
= rqrr::PreparedImage::prepare(img); ui.scope_builder(UiBuilder::new().max_rect(rect.clone()), |ui| {
// Scan and save results. ui.centered_and_justified(|ui| {
let grids = img.detect_grids(); ui.label(
if let Some(g) = grids.get(0) { RichText::new(format!("{}%", self.ur_progress()))
let mut qr_data = vec![]; .size(32.0)
if let Ok(_) = g.decode_to(&mut qr_data) { .color(Colors::gold_dark()),
// 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. /// Draw camera loading progress content.
thread::spawn(move || { fn loading_ui(&self, ui: &mut egui::Ui) -> Rect {
tokio::runtime::Builder::new_multi_thread() let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0;
.enable_all() ui.vertical_centered(|ui| {
.build() ui.add_space(space);
.unwrap() View::big_loading_spinner(ui);
.block_on(on_scan); ui.add_space(space);
}); })
} .response
.rect
}
/// Parse QR code scan result. /// Check if image is processing to find QR code.
fn parse_qr_code(data: Vec<u8>) -> QrScanResult { fn image_processing(&self) -> bool {
// Check if string starts with Grin address prefix. let r_scan = self.qr_scan_state.read();
let text_string = String::from_utf8(data.clone()).unwrap_or("".to_string()); r_scan.image_processing
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. /// Get UR scanning progress in percents.
if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") { fn ur_progress(&self) -> i32 {
return QrScanResult::Slatepack(ZeroingString::from(text)); // 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
}
// Check Uniform Resource data. /// Parse QR code from provided image data.
// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md fn scan_qr(&self, image_data: &DynamicImage) {
if text.starts_with("ur:bytes/") { // Do not scan when another image is processing.
let split = text.split("/").collect::<Vec<_>>(); if self.image_processing() {
if let Some(index_total) = split.get(1) { return;
if let Some((index, total)) = index_total.split_once("-") { }
let index = index.parse::<usize>(); // Setup scanning flag.
let total = total.parse::<usize>(); {
if index.is_ok() && total.is_ok() { let mut w_scan = self.qr_scan_state.write();
let index = index.unwrap() - 1; w_scan.image_processing = true;
let total = total.unwrap(); }
return QrScanResult::URPart(text_string, index, total);
}
}
}
}
// Check Compact SeedQR format. let image_data = image_data.clone();
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#compactseedqr-specification let qr_scan_state = self.qr_scan_state.clone();
if data.len() <= 32 && 16 <= data.len() && data.len() % 4 == 0 { let ur_data = self.ur_data.clone();
// 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. let on_scan = async move {
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#standard-seedqr-specification // Prepare image data.
let only_numbers = || { let img = image_data.to_luma8();
for c in text.chars() { let mut img: rqrr::PreparedImage<image::GrayImage> = rqrr::PreparedImage::prepare(img);
if !c.is_numeric() { // Scan and save results.
return false; let grids = img.detect_grids();
} if let Some(g) = grids.get(0) {
} let mut qr_data = vec![];
true if let Ok(_) = g.decode_to(&mut qr_data) {
}; // Setup scanned data into text.
if !text.is_empty() && data.len() <= 96 && data.len() % 4 == 0 && only_numbers() { let text = String::from_utf8(qr_data.clone()).unwrap_or("".to_string());
if let Some(_) = PhraseSize::type_for_value(text.len() / 4) { // Setup current text.
let chars: Vec<char> = text.trim().chars().collect(); let cur_text = {
let split = &chars.chunks(4) let r_scan = qr_scan_state.read();
.map(|chunk| chunk.iter().collect::<String>() let text = if let Some(res) = r_scan.qr_scan_result.clone() {
.trim() res.text()
.trim_start_matches("0") } else {
.to_string() "".to_string()
) };
.collect::<Vec<_>>(); text
let mut words = "".to_string(); };
for i in split { // Parse non-empty data if parsed text is different from saved.
let index = if i.is_empty() { if !qr_data.is_empty() && (cur_text.is_empty() || text != cur_text) {
0usize let res = Self::parse_qr_code(qr_data);
} else { match res {
i.parse::<usize>().unwrap_or(WORDS.len()) QrScanResult::URPart(uri, index, total) => {
}; // Setup current UR data.
let empty_word = "".to_string(); let mut cur_data = {
let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone(); let r_data = ur_data.read();
// Return text result when BIP39 word was not found. let mut cur_data = vec!["".to_string(); total];
if word.is_empty() { if let Some((d, _)) = r_data.clone() {
return QrScanResult::Text(ZeroingString::from(text)); cur_data = d;
} }
words = if words.is_empty() { cur_data
format!("{}", word) };
} else { if !cur_data.contains(&uri) {
format!("{} {}", words, word) // Save part of UR data.
}; {
} cur_data.insert(index, uri);
return QrScanResult::SeedQR(ZeroingString::from(words)); 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;
}
};
// Return default text result. // Launch scanner at separate thread.
QrScanResult::Text(ZeroingString::from(text)) thread::spawn(move || {
} tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(on_scan);
});
}
/// Get QR code scan result. /// Parse QR code scan result.
pub fn qr_scan_result(&self) -> Option<QrScanResult> { fn parse_qr_code(data: Vec<u8>) -> QrScanResult {
let r_scan = self.qr_scan_state.read(); // Check if string starts with Grin address prefix.
if r_scan.qr_scan_result.is_some() { let text_string = String::from_utf8(data.clone()).unwrap_or("".to_string());
return Some(r_scan.qr_scan_result.clone().unwrap()); let text = text_string.trim();
} if text.starts_with("tgrin") || text.starts_with("grin") {
None if SlatepackAddress::try_from(text).is_ok() {
} return QrScanResult::Address(ZeroingString::from(text));
}
}
/// Reset camera content state to default. // Check if string contains Slatepack message prefix and postfix.
pub fn clear_state(&mut self) { if text.starts_with("BEGINSLATEPACK.") && text.ends_with("ENDSLATEPACK.") {
// Clear QR code scanning state. return QrScanResult::Slatepack(text.to_string());
let mut w_scan = self.qr_scan_state.write(); }
*w_scan = QrScanState::default();
// Clear UR data. // Check Uniform Resource data.
let mut w_data = self.ur_data.write(); // https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md
*w_data = None; 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 Standard SeedQR format.
// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md#standard-seedqr-specification
let only_numbers = || {
for c in text.chars() {
if !c.is_numeric() {
return false;
}
}
true
};
if !text.is_empty() && data.len() <= 96 && data.len() % 4 == 0 && only_numbers() {
if let Some(_) = PhraseSize::type_for_value(text.len() / 4) {
let chars: Vec<char> = text.trim().chars().collect();
let split = &chars
.chunks(4)
.map(|chunk| {
chunk
.iter()
.collect::<String>()
.trim()
.trim_start_matches("0")
.to_string()
})
.collect::<Vec<_>>();
let mut words = "".to_string();
for i in split {
let index = if i.is_empty() {
0usize
} else {
i.parse::<usize>().unwrap_or(WORDS.len())
};
let empty_word = "".to_string();
let word = WORDS.get(index).clone().unwrap_or(&empty_word).clone();
// Return text result when BIP39 word was not found.
if word.is_empty() {
return QrScanResult::Text(ZeroingString::from(text));
}
words = if words.is_empty() {
format!("{}", word)
} else {
format!("{} {}", words, word)
};
}
return QrScanResult::SeedQR(ZeroingString::from(words));
}
}
// Return default text result.
QrScanResult::Text(ZeroingString::from(text))
}
/// Get QR code scan result.
pub fn qr_scan_result(&self) -> Option<QrScanResult> {
let r_scan = self.qr_scan_state.read();
if r_scan.qr_scan_result.is_some() {
return Some(r_scan.qr_scan_result.clone().unwrap());
}
None
}
}
+299 -391
View File
@@ -12,432 +12,340 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::RichText;
use egui::os::OperatingSystem;
use lazy_static::lazy_static;
use std::fs; use std::fs;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use egui::os::OperatingSystem;
use egui::{Align, Layout, RichText};
use lazy_static::lazy_static;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::FILE_X;
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::network::NetworkContent;
use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::wallets::WalletsContent;
use crate::gui::views::{Modal, View}; use crate::gui::views::{Modal, View};
use crate::gui::views::types::{ModalContainer, ModalPosition};
use crate::node::Node; use crate::node::Node;
use crate::{AppConfig, Settings}; use crate::{AppConfig, Settings};
use crate::gui::icons::{CHECK, CHECK_FAT, FILE_X};
use crate::gui::views::network::{NetworkContent, NodeSetup};
use crate::gui::views::wallets::WalletsContent;
lazy_static! { lazy_static! {
/// Global state to check if [`NetworkContent`] panel is open. /// Global state to check if [`NetworkContent`] panel is open.
static ref NETWORK_PANEL_OPEN: AtomicBool = AtomicBool::new(false); static ref NETWORK_PANEL_OPEN: AtomicBool = AtomicBool::new(false);
} }
/// Contains main ui content, handles side panel state. /// Contains main ui content, handles side panel state.
pub struct Content { pub struct Content {
/// Side panel [`NetworkContent`] content. /// Side panel [`NetworkContent`] content.
network: NetworkContent, network: NetworkContent,
/// Central panel [`WalletsContent`] content.
pub wallets: WalletsContent,
/// Check if app exit is allowed on close event of [`eframe::App`] implementation. /// Central panel [`WalletsContent`] content.
pub(crate) exit_allowed: bool, wallets: WalletsContent,
/// Flag to show exit progress at [`Modal`].
show_exit_progress: bool,
/// Flag to check it's first draw of content. /// Check if app exit is allowed on Desktop close event.
first_draw: bool, pub exit_allowed: bool,
/// Flag to show exit progress at [`Modal`].
show_exit_progress: bool,
/// List of allowed [`Modal`] ids for this [`ModalContainer`]. /// Flag to check it's first draw of content.
allowed_modal_ids: Vec<&'static str> first_draw: bool,
} }
impl Default for Content { impl Default for Content {
fn default() -> Self { fn default() -> Self {
// Exit from eframe only for non-mobile platforms. // Exit from eframe only for non-mobile platforms.
let os = OperatingSystem::from_target_os(); let os = OperatingSystem::from_target_os();
let exit_allowed = os == OperatingSystem::Android || os == OperatingSystem::IOS; let exit_allowed = os == OperatingSystem::Android || os == OperatingSystem::IOS;
Self { Self {
network: NetworkContent::default(), network: NetworkContent::default(),
wallets: WalletsContent::default(), wallets: WalletsContent::default(),
exit_allowed, exit_allowed,
show_exit_progress: false, show_exit_progress: false,
first_draw: true, first_draw: true,
allowed_modal_ids: vec![ }
Self::EXIT_CONFIRMATION_MODAL, }
Self::SETTINGS_MODAL,
Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL,
Self::CRASH_REPORT_MODAL
],
}
}
} }
impl ModalContainer for Content { /// Identifier for integrated node warning [`Modal`] on Android.
fn modal_ids(&self) -> &Vec<&'static str> { const ANDROID_INTEGRATED_NODE_WARNING_MODAL: &'static str = "android_node_warning_modal";
&self.allowed_modal_ids /// Identifier for crash report [`Modal`].
} const CRASH_REPORT_MODAL: &'static str = "crash_report_modal";
fn modal_ui(&mut self, impl ContentContainer for Content {
ui: &mut egui::Ui, fn modal_ids(&self) -> Vec<&'static str> {
modal: &Modal, vec![
cb: &dyn PlatformCallbacks) { Self::EXIT_CONFIRMATION_MODAL,
match modal.id { ANDROID_INTEGRATED_NODE_WARNING_MODAL,
Self::EXIT_CONFIRMATION_MODAL => self.exit_modal_content(ui, modal), CRASH_REPORT_MODAL,
Self::SETTINGS_MODAL => self.settings_modal_ui(ui, modal), ]
Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL => self.android_warning_modal_ui(ui, modal), }
Self::CRASH_REPORT_MODAL => self.crash_report_modal_ui(ui, modal, cb),
_ => {} 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();
}
// 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 wallets content.
egui::CentralPanel::default()
.frame(egui::Frame {
..Default::default()
})
.show_inside(ui, |ui| {
self.wallets.ui(ui, cb);
});
if self.first_draw {
// Show crash report or integrated node Android warning.
if Settings::crash_check_path().exists() {
Modal::new(CRASH_REPORT_MODAL)
.closeable(false)
.position(ModalPosition::Center)
.title(t!("crash_report"))
.show();
} else if OperatingSystem::from_target_os() == OperatingSystem::Android
&& AppConfig::android_integrated_node_warning_needed()
{
Modal::new(ANDROID_INTEGRATED_NODE_WARNING_MODAL)
.title(t!("network.node"))
.show();
}
self.first_draw = false;
}
}
} }
impl Content { impl Content {
/// Identifier for exit confirmation [`Modal`]. /// Default width of side panel at application UI.
pub const EXIT_CONFIRMATION_MODAL: &'static str = "exit_confirmation_modal"; pub const SIDE_PANEL_WIDTH: f32 = 400.0;
/// Identifier for wallet opening [`Modal`]. /// Desktop window title height.
pub const SETTINGS_MODAL: &'static str = "settings_modal"; pub const WINDOW_TITLE_HEIGHT: f32 = 38.0;
/// Identifier for integrated node warning [`Modal`] on Android. /// Margin of window frame at desktop.
const ANDROID_INTEGRATED_NODE_WARNING_MODAL: &'static str = "android_node_warning_modal"; pub const WINDOW_FRAME_MARGIN: f32 = 6.0;
/// Identifier for crash report [`Modal`].
const CRASH_REPORT_MODAL: &'static str = "crash_report_modal";
/// Default width of side panel at application UI. /// Identifier for exit confirmation [`Modal`].
pub const SIDE_PANEL_WIDTH: f32 = 400.0; pub const EXIT_CONFIRMATION_MODAL: &'static str = "exit_confirmation_modal";
/// 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;
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { /// Called to navigate back, return `true` if action was not consumed.
// Draw modal content for current ui container. pub fn on_back(&mut self, ctx: &egui::Context, cb: &dyn PlatformCallbacks) -> bool {
self.current_modal_ui(ui, cb); if Modal::on_back() {
let dual_panel = Self::is_dual_panel_mode(ctx);
if !dual_panel && Self::is_network_panel_open() {
if self.network.on_back() {
Self::toggle_network_panel();
return false;
}
} else if self.wallets.on_back(cb) {
Self::show_exit_modal();
return false;
}
}
true
}
let dual_panel = Self::is_dual_panel_mode(ui); /// Check if ui can show [`NetworkContent`] and [`WalletsContent`] at same time.
let (is_panel_open, panel_width) = Self::network_panel_state_width(ui, dual_panel); pub fn is_dual_panel_mode(ctx: &egui::Context) -> bool {
let (w, h) = View::window_size(ctx);
// Screen is wide if width is greater than height or just 20% smaller.
let is_wide_screen = w > h || w + (w * 0.2) >= h;
// Dual panel mode is available when window is wide and its width is at least 2 times
// greater than minimal width of the side panel plus display insets from both sides.
let side_insets = View::get_left_inset() + View::get_right_inset();
is_wide_screen && w >= (Self::SIDE_PANEL_WIDTH * 2.0) + side_insets
}
// Show network content. /// Toggle [`NetworkContent`] panel state.
egui::SidePanel::left("network_panel") pub fn toggle_network_panel() {
.resizable(false) let is_open = NETWORK_PANEL_OPEN.load(Ordering::Relaxed);
.exact_width(panel_width) NETWORK_PANEL_OPEN.store(!is_open, Ordering::Relaxed);
.frame(egui::Frame { }
..Default::default()
})
.show_animated_inside(ui, is_panel_open, |ui| {
self.network.ui(ui, cb);
});
// Show wallets content. /// Check if [`NetworkContent`] panel is open.
egui::CentralPanel::default() pub fn is_network_panel_open() -> bool {
.frame(egui::Frame { NETWORK_PANEL_OPEN.load(Ordering::Relaxed)
..Default::default() }
})
.show_inside(ui, |ui| {
self.wallets.ui(ui, cb);
});
if self.first_draw { /// Show exit confirmation [`Modal`].
// Show crash report if needed. pub fn show_exit_modal() {
if AppConfig::show_crash() { Modal::new(Self::EXIT_CONFIRMATION_MODAL)
Modal::new(Self::CRASH_REPORT_MODAL) .title(t!("confirmation"))
.closeable(false) .show();
.position(ModalPosition::Center) }
.title(t!("crash_report"))
.show();
} else {
// Show integrated node warning on Android if needed.
if OperatingSystem::from_target_os() == OperatingSystem::Android &&
AppConfig::android_integrated_node_warning_needed() {
Modal::new(Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL)
.title(t!("network.node"))
.show();
}
}
self.first_draw = false;
}
}
/// Get [`NetworkContent`] panel state and width. /// Draw exit confirmation modal content.
fn network_panel_state_width(ui: &mut egui::Ui, dual_panel: bool) -> (bool, f32) { fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
let is_panel_open = dual_panel || Self::is_network_panel_open(); if self.show_exit_progress {
let panel_width = if dual_panel { if !Node::is_running() && !Node::data_dir_changing() {
Self::SIDE_PANEL_WIDTH + View::get_left_inset() self.exit_allowed = true;
} else { cb.exit();
let is_fullscreen = ui.ctx().input(|i| { Modal::close();
i.viewport().fullscreen.unwrap_or(false) }
}); ui.add_space(16.0);
View::window_size(ui).0 - if View::is_desktop() && !is_fullscreen && ui.vertical_centered(|ui| {
OperatingSystem::from_target_os() != OperatingSystem::Mac { View::small_loading_spinner(ui);
Self::WINDOW_FRAME_MARGIN * 2.0 ui.add_space(12.0);
} else { let exit_status_text = if Node::data_dir_changing() {
0.0 t!("moving_files")
} } else {
}; t!("sync_status.shutdown")
(is_panel_open, panel_width) };
} ui.label(
RichText::new(exit_status_text)
.size(17.0)
.color(Colors::text(false)),
);
});
ui.add_space(10.0);
} else {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("modal_exit.description"))
.size(17.0)
.color(Colors::text(false)),
);
});
ui.add_space(10.0);
/// Check if ui can show [`NetworkContent`] and [`WalletsContent`] at same time. // Setup spacing between buttons.
pub fn is_dual_panel_mode(ui: &egui::Ui) -> bool { ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
let (w, h) = View::window_size(ui);
// Screen is wide if width is greater than height or just 20% smaller.
let is_wide_screen = w > h || w + (w * 0.2) >= h;
// Dual panel mode is available when window is wide and its width is at least 2 times
// greater than minimal width of the side panel plus display insets from both sides.
let side_insets = View::get_left_inset() + View::get_right_inset();
is_wide_screen && w >= (Self::SIDE_PANEL_WIDTH * 2.0) + side_insets
}
/// Toggle [`NetworkContent`] panel state. ui.columns(2, |columns| {
pub fn toggle_network_panel() { columns[0].vertical_centered_justified(|ui| {
let is_open = NETWORK_PANEL_OPEN.load(Ordering::Relaxed); View::button(
NETWORK_PANEL_OPEN.store(!is_open, Ordering::Relaxed); ui,
} t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button_ui(
ui,
t!("modal_exit.exit"),
Colors::white_or_black(false),
|_| {
if !Node::is_running() && !Node::data_dir_changing() {
self.exit_allowed = true;
cb.exit();
Modal::close();
} else {
Node::stop(true);
modal.disable_closing();
Modal::set_title(t!("modal_exit.exit"));
self.show_exit_progress = true;
}
},
);
});
});
ui.add_space(6.0);
}
}
/// Check if [`NetworkContent`] panel is open. /// Draw content for integrated node warning [`Modal`] on Android.
pub fn is_network_panel_open() -> bool { fn android_warning_modal_ui(&mut self, ui: &mut egui::Ui) {
NETWORK_PANEL_OPEN.load(Ordering::Relaxed) ui.add_space(6.0);
} ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("network.android_warning"))
.size(16.0)
.color(Colors::text(false)),
);
});
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
AppConfig::show_android_integrated_node_warning();
Modal::close();
});
});
ui.add_space(6.0);
}
/// Show exit confirmation [`Modal`]. /// Draw content for integrated node warning [`Modal`] on Android.
pub fn show_exit_modal() { fn crash_report_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
Modal::new(Self::EXIT_CONFIRMATION_MODAL) ui.add_space(6.0);
.title(t!("modal.confirmation")) ui.vertical_centered(|ui| {
.show(); ui.label(
} RichText::new(t!("crash_report_warning"))
.size(16.0)
.color(Colors::text(false)),
);
ui.add_space(6.0);
// Draw button to share log file.
let text = format!("{} {}", FILE_X, t!("share"));
View::colored_text_button(
ui,
text,
Colors::blue(),
Colors::white_or_black(false),
|| {
if let Ok(data) = fs::read_to_string(Settings::log_path()) {
let name = Settings::LOG_FILE_NAME.to_string();
let _ = cb.share_data(name, data.as_bytes().to_vec());
}
Settings::delete_crash_check();
Modal::close();
},
);
});
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Settings::delete_crash_check();
Modal::close();
},
);
});
ui.add_space(6.0);
}
}
/// Draw exit confirmation modal content. /// Get [`NetworkContent`] panel state and width.
fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal) { fn network_panel_state_width(ctx: &egui::Context, dual_panel: bool) -> (bool, f32) {
if self.show_exit_progress { let is_panel_open = dual_panel || Content::is_network_panel_open();
if !Node::is_running() { let panel_width = if dual_panel {
self.exit_allowed = true; Content::SIDE_PANEL_WIDTH + View::get_left_inset()
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); } else {
modal.close(); let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
} View::window_size(ctx).0
ui.add_space(16.0); - if View::is_desktop()
ui.vertical_centered(|ui| { && !is_fullscreen
View::small_loading_spinner(ui); && OperatingSystem::from_target_os() != OperatingSystem::Mac
ui.add_space(12.0); {
ui.label(RichText::new(t!("sync_status.shutdown")) Content::WINDOW_FRAME_MARGIN * 2.0
.size(17.0) } else {
.color(Colors::text(false))); 0.0
}); }
ui.add_space(10.0); };
} else { (is_panel_open, panel_width)
ui.add_space(8.0); }
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("modal_exit.description"))
.size(17.0)
.color(Colors::text(false)));
});
ui.add_space(10.0);
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
modal.close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button_ui(ui, t!("modal_exit.exit"), Colors::white_or_black(false), |ui| {
if !Node::is_running() {
self.exit_allowed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
modal.close();
} else {
Node::stop(true);
modal.disable_closing();
Modal::set_title(t!("modal_exit.exit"));
self.show_exit_progress = true;
}
});
});
});
ui.add_space(6.0);
}
}
/// Handle Back key event.
pub fn on_back(&mut self) {
if Modal::on_back() {
if self.wallets.on_back() {
Self::show_exit_modal()
}
}
}
/// Draw creating wallet name/password input [`Modal`] content.
pub fn settings_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal) {
ui.add_space(6.0);
// Draw chain type selection.
NodeSetup::chain_type_ui(ui);
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Draw theme selection.
Self::theme_selection_ui(ui);
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(format!("{}:", t!("language")))
.size(16.0)
.color(Colors::gray())
);
});
ui.add_space(8.0);
// Draw available list of languages to select.
let locales = rust_i18n::available_locales!();
for (index, locale) in locales.iter().enumerate() {
Self::language_item_ui(locale, ui, index, locales.len(), modal);
}
ui.add_space(8.0);
// Show button to close modal.
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("close"), Colors::white_or_black(false), || {
modal.close();
});
});
ui.add_space(6.0);
}
/// Draw theme selection content.
fn theme_selection_ui(ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("theme")).size(16.0).color(Colors::gray()));
});
let saved_use_dark = AppConfig::dark_theme().unwrap_or(false);
let mut selected_use_dark = saved_use_dark;
ui.add_space(8.0);
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_use_dark, false, t!("light"));
});
columns[1].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_use_dark, true, t!("dark"));
})
});
ui.add_space(8.0);
if saved_use_dark != selected_use_dark {
AppConfig::set_dark_theme(selected_use_dark);
crate::setup_visuals(ui.ctx());
}
}
/// Draw language selection item content.
fn language_item_ui(locale: &str, ui: &mut egui::Ui, index: usize, len: usize, modal: &Modal) {
// Setup layout size.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(50.0);
// Draw round background.
let bg_rect = rect.clone();
let item_rounding = View::item_rounding(index, len, false);
ui.painter().rect(bg_rect, item_rounding, Colors::fill(), View::item_stroke());
ui.vertical(|ui| {
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| {
// Draw button to select language.
let is_current = if let Some(lang) = AppConfig::locale() {
lang == locale
} else {
rust_i18n::locale() == locale
};
if !is_current {
View::item_button(ui, View::item_rounding(index, len, true), CHECK, None, || {
rust_i18n::set_locale(locale);
AppConfig::save_locale(locale);
modal.close();
});
} else {
ui.add_space(14.0);
ui.label(RichText::new(CHECK_FAT).size(20.0).color(Colors::green()));
ui.add_space(14.0);
}
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| {
ui.add_space(12.0);
ui.vertical(|ui| {
// Draw language name.
ui.add_space(12.0);
let color = if is_current {
Colors::title(false)
} else {
Colors::gray()
};
ui.label(RichText::new(t!("lang_name", locale = locale))
.size(17.0)
.color(color));
ui.add_space(3.0);
});
});
});
});
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn android_warning_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("network.android_warning"))
.size(16.0)
.color(Colors::text(false)));
});
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
AppConfig::show_android_integrated_node_warning();
modal.close();
});
});
ui.add_space(6.0);
}
/// Draw content for integrated node warning [`Modal`] on Android.
fn crash_report_modal_ui(&mut self,
ui: &mut egui::Ui,
modal: &Modal,
cb: &dyn PlatformCallbacks) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("crash_report_warning"))
.size(16.0)
.color(Colors::text(false)));
ui.add_space(6.0);
// Draw button to share crash report.
let text = format!("{} {}", FILE_X, t!("share"));
View::colored_text_button(ui, text, Colors::blue(), Colors::white_or_black(false), || {
if let Ok(data) = fs::read_to_string(Settings::crash_report_path()) {
cb.share_data(Settings::CRASH_REPORT_FILE_NAME.to_string(),
data.as_bytes().to_vec()).unwrap_or_default()
}
AppConfig::set_show_crash(false);
modal.close();
});
});
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
AppConfig::set_show_crash(false);
modal.close();
});
});
ui.add_space(6.0);
}
}
-116
View File
@@ -1,116 +0,0 @@
// Copyright 2024 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{fs, thread};
use parking_lot::RwLock;
use crate::gui::Colors;
use crate::gui::icons::ARCHIVE_BOX;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::View;
/// Button to pick file and parse its data into text.
pub struct FilePickButton {
/// Flag to check if file is picking.
pub file_picking: Arc<AtomicBool>,
/// Flag to check if file is parsing.
pub file_parsing: Arc<AtomicBool>,
/// File parsing result.
pub file_parsing_result: Arc<RwLock<Option<String>>>
}
impl Default for FilePickButton {
fn default() -> Self {
Self {
file_picking: Arc::new(AtomicBool::new(false)),
file_parsing: Arc::new(AtomicBool::new(false)),
file_parsing_result: Arc::new(RwLock::new(None))
}
}
}
impl FilePickButton {
/// Draw button content.
pub fn ui(&mut self,
ui: &mut egui::Ui,
cb: &dyn PlatformCallbacks,
on_result: impl FnOnce(String)) {
if self.file_picking.load(Ordering::Relaxed) {
View::small_loading_spinner(ui);
// Check file pick result.
if let Some(path) = cb.picked_file() {
self.file_picking.store(false, Ordering::Relaxed);
if !path.is_empty() {
self.on_file_pick(path);
}
}
} else if self.file_parsing.load(Ordering::Relaxed) {
View::small_loading_spinner(ui);
// Check file parsing result.
let has_result = {
let r_res = self.file_parsing_result.read();
r_res.is_some()
};
if has_result {
let text = {
let r_res = self.file_parsing_result.read();
r_res.clone().unwrap()
};
// Callback on result.
on_result(text);
// Clear result.
let mut w_res = self.file_parsing_result.write();
*w_res = None;
self.file_parsing.store(false, Ordering::Relaxed);
}
} else {
// Draw button to pick file.
let file_text = format!("{} {}", ARCHIVE_BOX, t!("choose_file"));
View::colored_text_button(ui, file_text, Colors::blue(), Colors::button(), || {
if let Some(path) = cb.pick_file() {
self.on_file_pick(path);
}
});
}
}
/// Handle picked file path.
fn on_file_pick(&self, path: String) {
// Wait for asynchronous file pick result if path is empty.
if path.is_empty() {
self.file_picking.store(true, Ordering::Relaxed);
return;
}
self.file_parsing.store(true, Ordering::Relaxed);
let result = self.file_parsing_result.clone();
thread::spawn(move || {
if path.ends_with(".gif") {
//TODO: Detect QR codes on GIF file.
} else if path.ends_with(".jpeg") || path.ends_with(".jpg") ||
path.ends_with(".png") {
//TODO: Detect QR codes on image files.
} else {
// Parse file as plain text.
let mut w_res = result.write();
if let Ok(text) = fs::read_to_string(path) {
*w_res = Some(text);
} else {
*w_res = Some("".to_string());
}
}
});
}
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright 2024 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::CornerRadius;
use parking_lot::RwLock;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{fs, thread};
use crate::gui::Colors;
use crate::gui::icons::ARCHIVE_BOX;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::View;
/// Type of button.
pub enum FilePickContentType {
Button(String),
ItemButton(CornerRadius),
Tab,
}
/// Button to pick file and parse its data into text.
pub struct FilePickContent {
/// Content type.
content_type: FilePickContentType,
/// Flag to check if button is active.
active: bool,
/// Flag to check if file is picking.
file_picking: Arc<AtomicBool>,
/// Flag to check if folder should be picked.
pick_folder: bool,
/// Flag to parse file content after pick.
parse_file: bool,
/// Flag to check if file is parsing.
file_parsing: Arc<AtomicBool>,
/// File parsing result.
file_parsing_result: Arc<RwLock<Option<String>>>,
}
impl FilePickContent {
/// Create new content from provided type.
pub fn new(content_type: FilePickContentType) -> Self {
Self {
content_type,
active: false,
file_picking: Arc::new(AtomicBool::new(false)),
pick_folder: false,
parse_file: true,
file_parsing: Arc::new(AtomicBool::new(false)),
file_parsing_result: Arc::new(RwLock::new(None)),
}
}
/// Pick folder.
pub fn pick_folder(mut self) -> Self {
self.pick_folder = true;
self
}
/// Do not parse file content.
pub fn no_parse(mut self) -> Self {
self.parse_file = false;
self
}
/// Enable or disable the button.
pub fn set_active(&mut self, active: bool) {
self.active = active;
}
/// Draw content with provided callback to return path of the file.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks, pick: impl FnOnce(String)) {
if self.file_picking.load(Ordering::Relaxed) {
View::small_loading_spinner(ui);
// Check file pick result.
if let Some(path) = cb.picked_file() {
self.file_picking.store(false, Ordering::Relaxed);
if !path.is_empty() {
if self.parse_file {
self.parse_file(path);
} else {
pick(path);
}
}
}
} else if self.file_parsing.load(Ordering::Relaxed) {
View::small_loading_spinner(ui);
// Check file parsing result.
let has_result = {
let r_res = self.file_parsing_result.read();
r_res.is_some()
};
if has_result {
let text = {
let r_res = self.file_parsing_result.read();
r_res.clone().unwrap()
};
// Callback on result.
pick(text);
// Clear result.
let mut w_res = self.file_parsing_result.write();
*w_res = None;
self.file_parsing.store(false, Ordering::Relaxed);
}
} else {
// Draw button to pick file.
match &self.content_type {
FilePickContentType::Button(text) => {
let text = format!("{} {}", ARCHIVE_BOX, text);
let text_color = Colors::blue();
let fill = Colors::white_or_black(false);
View::colored_text_button(ui, text, text_color, fill, || {
self.on_file_pick(pick, cb);
});
}
FilePickContentType::ItemButton(r) => {
View::item_button(ui, r.clone(), ARCHIVE_BOX, Some(Colors::blue()), || {
self.on_file_pick(pick, cb);
});
}
FilePickContentType::Tab => {
let active = match self.active {
true => Some(
self.file_parsing.load(Ordering::Relaxed)
|| self.file_picking.load(Ordering::Relaxed),
),
false => None,
};
View::tab_button(ui, ARCHIVE_BOX, Some(Colors::blue()), active, |_| {
self.on_file_pick(pick, cb);
});
}
}
}
}
/// Handle pick file request.
fn on_file_pick(&self, on_pick: impl FnOnce(String), cb: &dyn PlatformCallbacks) {
let path = if self.pick_folder {
cb.pick_folder()
} else {
cb.pick_file()
};
if path.is_none() {
return;
}
let path = path.unwrap();
// Wait for asynchronous file pick result if path is empty.
if path.is_empty() {
self.file_picking.store(true, Ordering::Relaxed);
return;
}
// Parse result if needed.
if self.parse_file {
self.parse_file(path);
} else {
on_pick(path);
}
}
/// Handle picked file path.
fn parse_file(&self, path: String) {
self.file_parsing.store(true, Ordering::Relaxed);
let result = self.file_parsing_result.clone();
thread::spawn(move || {
if path.ends_with(".gif") {
//TODO: Detect QR codes on GIF file.
} else if path.ends_with(".jpeg") || path.ends_with(".jpg") || path.ends_with(".png") {
//TODO: Detect QR codes on image files.
} else {
// Parse file as plain text.
let mut w_res = result.write();
if let Ok(text) = fs::read_to_string(path) {
*w_res = Some(text);
} else {
*w_res = Some("".to_string());
}
}
});
}
}
+475
View File
@@ -0,0 +1,475 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::text_edit::TextEditState;
use egui::{Align, Layout, TextBuffer, TextStyle, ViewportCommand, Widget};
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};
/// 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,
}
impl TextEdit {
/// 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(),
}
}
/// 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 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);
// 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 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);
// 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);
// 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();
}
// 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()
};
// 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;
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);
}
}
}
/// 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
}
/// 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;
}
/// 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
}
/// 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 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
}
}
/// Check if current system is Android.
fn is_android() -> bool {
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));
}
#[allow(dead_code)]
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
#[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,
) {
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(_) => {}
}
}
}
#[allow(dead_code)]
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
#[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,
) {
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)]
#[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,
) {
let mut w_input = LAST_SOFT_KEYBOARD_EVENT.write();
*w_input = Some(KeyboardEvent::ENTER);
}
+549
View File
@@ -0,0 +1,549 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::{
Align, Align2, Button, Color32, CursorIcon, Layout, Margin, Rect, Response, RichText, Sense,
Shadow, Vec2, Widget,
};
use lazy_static::lazy_static;
use parking_lot::RwLock;
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};
lazy_static! {
/// 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,
}
impl Default for KeyboardContent {
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;
/// 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;
// 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);
}
// 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 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]);
}
});
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]);
}
}
});
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]);
}
});
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_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
}
/// 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_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]);
}
}
});
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();
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
}
/// 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
}
/// 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();
}
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
mod types;
pub use types::*;
mod edit;
pub use edit::*;
mod keyboard;
pub use keyboard::*;
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
/// Software keyboard input type.
#[derive(Clone, PartialOrd, PartialEq)]
pub enum KeyboardLayout {
TEXT,
SYMBOLS,
NUMBERS,
}
/// Software keyboard input event.
#[derive(Clone)]
pub enum KeyboardEvent {
TEXT(String),
CLEAR,
ENTER,
}
/// Software keyboard Window State.
#[derive(Clone)]
pub struct KeyboardState {
/// Last input event.
pub last_event: Arc<Option<KeyboardEvent>>,
/// Current layout.
pub layout: Arc<KeyboardLayout>,
/// Flag to enter uppercase symbol first.
pub shift: Arc<AtomicBool>,
}
impl Default for KeyboardState {
fn default() -> Self {
Self {
last_event: Arc::new(None),
layout: Arc::new(KeyboardLayout::TEXT),
shift: Arc::new(AtomicBool::new(false)),
}
}
}
+10 -4
View File
@@ -27,7 +27,7 @@ mod content;
pub use content::*; pub use content::*;
pub mod network; pub mod network;
pub mod settings;
pub mod wallets; pub mod wallets;
mod camera; mod camera;
@@ -36,8 +36,14 @@ pub use camera::*;
mod qr; mod qr;
pub use qr::*; pub use qr::*;
mod file; mod file_pick;
pub use file::*; pub use file_pick::*;
mod pull_to_refresh; mod pull_to_refresh;
pub use pull_to_refresh::*; pub use pull_to_refresh::*;
mod scan;
pub use scan::*;
mod input;
pub use input::*;
Regular → Executable
+328 -276
View File
@@ -12,326 +12,378 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use lazy_static::lazy_static;
use std::sync::Arc;
use parking_lot::RwLock;
use std::sync::atomic::{AtomicBool, Ordering};
use egui::{Align2, Rect, RichText, Rounding, Stroke, Vec2};
use egui::epaint::{RectShape, Shadow}; use egui::epaint::{RectShape, Shadow};
use egui::os::OperatingSystem; use egui::os::OperatingSystem;
use egui::{Align2, Color32, CornerRadius, RichText, Stroke, StrokeKind, UiBuilder, Vec2};
use lazy_static::lazy_static;
use parking_lot::RwLock;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::views::{Content, View}; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::{ModalPosition, ModalState}; use crate::gui::views::types::{ModalPosition, ModalState};
use crate::gui::views::{Content, View};
lazy_static! { lazy_static! {
/// Showing [`Modal`] state to be accessible from different ui parts. /// Showing [`Modal`] state to be accessible from different ui parts.
static ref MODAL_STATE: Arc<RwLock<ModalState>> = Arc::new(RwLock::new(ModalState::default())); static ref MODAL_STATE: Arc<RwLock<ModalState>> = Arc::new(RwLock::new(ModalState::default()));
} }
/// Stores data to draw modal [`egui::Window`] at ui. /// Modal [`egui::Window`] container.
#[derive(Clone)] #[derive(Clone)]
pub struct Modal { pub struct Modal {
/// Identifier for modal. /// Identifier for modal.
pub(crate) id: &'static str, pub(crate) id: &'static str,
/// Position on the screen. /// Position on the screen.
position: ModalPosition, pub position: ModalPosition,
/// To check if it can be closed. /// Flag to check if modal can be closed by keys.
closeable: Arc<AtomicBool>, closeable: Arc<AtomicBool>,
/// Title text /// Title text.
title: Option<String> title: Option<String>,
/// Flag to check first content render.
first_draw: Arc<AtomicBool>,
/// Background color.
fill: Option<Color32>,
} }
impl Modal { impl Modal {
/// Margin from [`Modal`] window at top/left/right. /// Margin from [`Modal`] window at top/left/right.
const DEFAULT_MARGIN: f32 = 8.0; const DEFAULT_MARGIN: f32 = 8.0;
/// Maximum width of the content. /// Maximum width of the content.
const DEFAULT_WIDTH: f32 = Content::SIDE_PANEL_WIDTH - (2.0 * Self::DEFAULT_MARGIN); const DEFAULT_WIDTH: f32 = Content::SIDE_PANEL_WIDTH - (2.0 * Self::DEFAULT_MARGIN);
/// Modal content [`egui::Window`] id.
pub const WINDOW_ID: &'static str = "modal_window";
/// Create closeable [`Modal`] with center position. /// Create closeable [`Modal`] with center position.
pub fn new(id: &'static str) -> Self { pub fn new(id: &'static str) -> Self {
Self { Self {
id, id,
position: ModalPosition::Center, position: ModalPosition::Center,
closeable: Arc::new(AtomicBool::new(true)), closeable: Arc::new(AtomicBool::new(true)),
title: None title: None,
} first_draw: Arc::new(AtomicBool::new(true)),
} fill: None,
}
}
/// Setup position of [`Modal`] on the screen. /// Setup position of [`Modal`] on the screen.
pub fn position(mut self, position: ModalPosition) -> Self { pub fn position(mut self, position: ModalPosition) -> Self {
self.position = position; self.position = position;
self self
} }
/// Mark [`Modal`] closed. /// Change [`Modal`] position on the screen.
pub fn close(&self) { pub fn change_position(position: ModalPosition) {
let mut w_nav = MODAL_STATE.write(); let mut w_state = MODAL_STATE.write();
w_nav.modal = None; w_state.modal.as_mut().unwrap().position = position;
} }
/// Setup possibility to close [`Modal`]. /// Close [`Modal`] by clearing its state.
pub fn closeable(self, closeable: bool) -> Self { pub fn close() {
self.closeable.store(closeable, Ordering::Relaxed); let mut w_nav = MODAL_STATE.write();
self w_nav.modal = None;
} }
/// Disable possibility to close [`Modal`]. /// Setup possibility to close [`Modal`].
pub fn disable_closing(&self) { pub fn closeable(self, closeable: bool) -> Self {
self.closeable.store(false, Ordering::Relaxed); self.closeable.store(closeable, Ordering::Relaxed);
} self
}
/// Enable possibility to close [`Modal`]. /// Disable possibility to close [`Modal`].
pub fn enable_closing(&self) { pub fn disable_closing(&self) {
self.closeable.store(true, Ordering::Relaxed); self.closeable.store(false, Ordering::Relaxed);
} }
/// Check if [`Modal`] is closeable. /// Enable possibility to close [`Modal`].
pub fn is_closeable(&self) -> bool { pub fn enable_closing(&self) {
self.closeable.load(Ordering::Relaxed) self.closeable.store(true, Ordering::Relaxed);
} }
/// Set title text on [`Modal`] creation. /// Check if [`Modal`] is closeable.
pub fn title(mut self, title: String) -> Self { pub fn is_closeable(&self) -> bool {
self.title = Some(title.to_uppercase()); self.closeable.load(Ordering::Relaxed)
self }
}
/// Set [`Modal`] instance into state to show at ui. /// Set title text on [`Modal`] creation.
pub fn show(self) { pub fn title(mut self, title: impl Into<String>) -> Self {
let mut w_nav = MODAL_STATE.write(); self.title = Some(title.into().to_uppercase());
w_nav.modal = Some(self); self
} }
/// Remove [`Modal`] from [`ModalState`] if it's showing and can be closed. /// Set [`Modal`] instance into state to show at ui.
/// Return `false` if Modal existed in [`ModalState`] before call. pub fn show(self) {
pub fn on_back() -> bool { let mut w_nav = MODAL_STATE.write();
let mut w_state = MODAL_STATE.write(); self.first_draw.store(true, Ordering::Relaxed);
w_nav.modal = Some(self);
}
// If Modal is showing and closeable, remove it from state. /// Remove [`Modal`] from [`ModalState`] if it's showing and can be closed.
if w_state.modal.is_some() { /// Return `false` if modal existed in state before call.
let modal = w_state.modal.as_ref().unwrap(); pub fn on_back() -> bool {
if modal.is_closeable() { if Self::opened().is_some() {
w_state.modal = None; if Self::opened_closeable() {
} Self::close();
return false; }
} return false;
true }
} true
}
/// Return id of opened [`Modal`]. /// Return identifier of opened [`Modal`].
pub fn opened() -> Option<&'static str> { pub fn opened() -> Option<&'static str> {
// Check if modal is showing. // Check if modal is showing.
{ {
if MODAL_STATE.read().modal.is_none() { if MODAL_STATE.read().modal.is_none() {
return None; return None;
} }
} }
// Get identifier of opened modal. // Get identifier of opened modal.
let r_state = MODAL_STATE.read(); let r_state = MODAL_STATE.read();
let modal = r_state.modal.as_ref().unwrap(); let modal = r_state.modal.as_ref().unwrap();
Some(modal.id) Some(modal.id)
} }
/// Set title text for current opened [`Modal`]. /// Check if [`Modal`] is opened and can be closed.
pub fn set_title(title: String) { pub fn opened_closeable() -> bool {
// Save state. // Check if modal is showing.
let mut w_state = MODAL_STATE.write(); {
if w_state.modal.is_some() { if MODAL_STATE.read().modal.is_none() {
let mut modal = w_state.modal.clone().unwrap(); return false;
modal.title = Some(title.to_uppercase()); }
w_state.modal = Some(modal); }
} let r_state = MODAL_STATE.read();
} let modal = r_state.modal.as_ref().unwrap();
modal.closeable.load(Ordering::Relaxed)
}
/// Draw opened [`Modal`] content. /// Set title text for current opened [`Modal`].
pub fn ui(ctx: &egui::Context, add_content: impl FnOnce(&mut egui::Ui, &Modal)) { pub fn set_title(title: impl Into<String>) {
let has_modal = { let mut w_state = MODAL_STATE.write();
MODAL_STATE.read().modal.is_some() if w_state.modal.is_some() {
}; let mut modal = w_state.modal.clone().unwrap();
if has_modal { modal.title = Some(title.into().to_uppercase());
let modal = { w_state.modal = Some(modal);
let r_state = MODAL_STATE.read(); }
r_state.modal.clone().unwrap() }
};
modal.window_ui(ctx, add_content);
}
}
/// Draw [`egui::Window`] with provided content. /// Check for first [`Modal`] content rendering.
fn window_ui(&self, ctx: &egui::Context, add_content: impl FnOnce(&mut egui::Ui, &Modal)) { pub fn first_draw() -> bool {
let is_fullscreen = ctx.input(|i| { if Self::opened().is_none() {
i.viewport().fullscreen.unwrap_or(false) return false;
}); }
let is_mac_os = OperatingSystem::from_target_os() == OperatingSystem::Mac; let r_state = MODAL_STATE.read();
let modal = r_state.modal.as_ref().unwrap();
modal.first_draw.load(Ordering::Relaxed)
}
let mut rect = ctx.screen_rect(); pub fn ui(
if View::is_desktop() && !is_mac_os { ctx: &egui::Context,
let margin = if !is_fullscreen { cb: &dyn PlatformCallbacks,
Content::WINDOW_FRAME_MARGIN add_content: impl FnOnce(&mut egui::Ui, &Modal, &dyn PlatformCallbacks),
} else { ) {
0.0 let has_modal = { MODAL_STATE.read().modal.is_some() };
}; if has_modal {
rect = rect.shrink(margin - 0.5); let modal = {
rect.min += egui::vec2(0.0, Content::WINDOW_TITLE_HEIGHT + 0.5); let r_state = MODAL_STATE.read();
rect.max.x += 0.5; r_state.modal.clone().unwrap()
} };
egui::Window::new("modal_bg_window") modal.window_ui(ctx, cb, add_content);
.title_bar(false) }
.resizable(false) }
.collapsible(false)
.fixed_rect(rect)
.frame(egui::Frame {
fill: Colors::semi_transparent(),
..Default::default()
})
.show(ctx, |ui| {
ui.set_min_size(rect.size());
});
// Setup width of modal content. /// Draw [`egui::Window`] with provided content.
let side_insets = View::get_left_inset() + View::get_right_inset(); fn window_ui(
let available_width = rect.width() - (side_insets + Self::DEFAULT_MARGIN); &self,
let width = f32::min(available_width, Self::DEFAULT_WIDTH); ctx: &egui::Context,
cb: &dyn PlatformCallbacks,
add_content: impl FnOnce(&mut egui::Ui, &Modal, &dyn PlatformCallbacks),
) {
let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
// Show main content Window at given position. // Setup background rect.
let (content_align, content_offset) = self.modal_position(is_fullscreen); let is_win = OperatingSystem::Windows == OperatingSystem::from_target_os();
let layer_id = egui::Window::new(format!("modal_window_{}", self.id)) let bg_rect = if View::is_desktop() && !is_win {
.title_bar(false) let mut r = ctx.content_rect();
.resizable(false) let is_mac = OperatingSystem::Mac == OperatingSystem::from_target_os();
.collapsible(false) if !is_mac && !is_fullscreen {
.min_width(width) r = r.shrink(Content::WINDOW_FRAME_MARGIN - 1.0);
.default_width(width) }
.anchor(content_align, content_offset) r.min.y += Content::WINDOW_TITLE_HEIGHT;
.frame(egui::Frame { r
shadow: Shadow { } else {
offset: Default::default(), ctx.content_rect()
blur: 30.0, };
spread: 3.0,
color: egui::Color32::from_black_alpha(32),
},
rounding: Rounding::same(8.0),
fill: Colors::fill(),
..Default::default()
})
.show(ctx, |ui| {
if self.title.is_some() {
self.title_ui(ui);
}
self.content_ui(ui, add_content);
}).unwrap().response.layer_id;
// Always show main content Window above background Window. // Draw modal background.
ctx.move_to_top(layer_id); egui::Window::new("modal_bg_window")
.title_bar(false)
.resizable(false)
.collapsible(false)
.fixed_rect(bg_rect)
.frame(egui::Frame {
fill: Colors::semi_transparent(),
..Default::default()
})
.show(ctx, |ui| {
ui.set_min_size(bg_rect.size());
});
} // Setup width of modal content.
let side_insets = View::get_left_inset() + View::get_right_inset();
let available_width = ctx.content_rect().width() - (side_insets + Self::DEFAULT_MARGIN);
let width = f32::min(available_width, Self::DEFAULT_WIDTH);
/// Get [`egui::Window`] position based on [`ModalPosition`]. // Show main content window at given position.
fn modal_position(&self, is_fullscreen: bool) -> (Align2, Vec2) { let (content_align, content_offset) = self.modal_position();
let align = match self.position { egui::Window::new(Self::WINDOW_ID)
ModalPosition::CenterTop => Align2::CENTER_TOP, .title_bar(false)
ModalPosition::Center => Align2::CENTER_CENTER .resizable(false)
}; .collapsible(false)
.min_width(width)
.default_width(width)
.anchor(content_align, content_offset)
.frame(egui::Frame {
shadow: Shadow {
offset: Default::default(),
blur: 30.0 as u8,
spread: 3.0 as u8,
color: egui::Color32::from_black_alpha(32),
},
corner_radius: CornerRadius::same(8.0 as u8),
..Default::default()
})
.show(ctx, |ui| {
if let Some(title) = &self.title {
title_ui(title, ui);
}
self.content_ui(ui, cb, add_content);
});
let x_align = View::get_left_inset() - View::get_right_inset(); // Setup first draw flag.
if Self::first_draw() {
let r_state = MODAL_STATE.read();
let modal = r_state.modal.as_ref().unwrap();
modal.first_draw.store(false, Ordering::Relaxed);
}
}
let is_mac_os = OperatingSystem::from_target_os() == OperatingSystem::Mac; /// Get [`egui::Window`] position based on [`ModalPosition`].
let extra_y = if View::is_desktop() && !is_mac_os { fn modal_position(&self) -> (Align2, Vec2) {
Content::WINDOW_TITLE_HEIGHT + if !is_fullscreen { let align = match self.position {
Content::WINDOW_FRAME_MARGIN ModalPosition::CenterTop => Align2::CENTER_TOP,
} else { ModalPosition::Center => Align2::CENTER_CENTER,
0.0 };
}
} else {
0.0
};
let y_align = View::get_top_inset() + Self::DEFAULT_MARGIN + extra_y;
let offset = match self.position { let x_align = View::get_left_inset() - View::get_right_inset();
ModalPosition::CenterTop => Vec2::new(x_align, y_align), let is_mac = OperatingSystem::Mac == OperatingSystem::from_target_os();
ModalPosition::Center => Vec2::new(x_align, 0.0) let is_win = OperatingSystem::Windows == OperatingSystem::from_target_os();
}; let extra_y = if View::is_desktop() && !is_win {
(align, offset) Content::WINDOW_TITLE_HEIGHT
} + if !is_mac {
Content::WINDOW_FRAME_MARGIN
} else {
0.0
}
} else {
0.0
};
let y_align = View::get_top_inset() + Self::DEFAULT_MARGIN / 2.0 + extra_y;
/// Draw provided content. let offset = match self.position {
fn content_ui(&self, ui: &mut egui::Ui, add_content: impl FnOnce(&mut egui::Ui, &Modal)) { ModalPosition::CenterTop => Vec2::new(x_align, y_align),
let mut rect = ui.available_rect_before_wrap(); ModalPosition::Center => Vec2::new(x_align, 0.0),
rect.min += egui::emath::vec2(6.0, 0.0); };
rect.max -= egui::emath::vec2(6.0, 0.0); (align, offset)
}
// Create background shape. /// Set custom background color.
let rounding = if self.title.is_some() { pub fn set_background_color(&self, color: Color32) {
Rounding { let mut w_state = MODAL_STATE.write();
nw: 0.0, w_state.modal.as_mut().unwrap().fill = Some(color);
ne: 0.0, }
sw: 8.0,
se: 8.0,
}
} else {
Rounding::same(8.0)
};
let mut bg_shape = RectShape {
rect,
rounding,
fill: Colors::fill(),
stroke: Stroke::NONE,
blur_width: 0.0,
fill_texture_id: Default::default(),
uv: Rect::ZERO
};
let bg_idx = ui.painter().add(bg_shape);
// Draw main content. /// Draw provided content.
let mut content_rect = ui.allocate_ui_at_rect(rect, |ui| { fn content_ui(
(add_content)(ui, self); &self,
}).response.rect; ui: &mut egui::Ui,
cb: &dyn PlatformCallbacks,
add_content: impl FnOnce(&mut egui::Ui, &Modal, &dyn PlatformCallbacks),
) {
let mut rect = ui.available_rect_before_wrap();
// Setup background shape to be painted behind main content. // Create background shape.
content_rect.min -= egui::emath::vec2(6.0, 0.0); let mut bg_shape = RectShape::new(
content_rect.max += egui::emath::vec2(6.0, 0.0); rect,
bg_shape.rect = content_rect; if self.title.is_none() {
ui.painter().set(bg_idx, bg_shape); CornerRadius::same(8.0 as u8)
} } else {
CornerRadius {
nw: 0.0 as u8,
ne: 0.0 as u8,
sw: 8.0 as u8,
se: 8.0 as u8,
}
},
self.fill.unwrap_or(Colors::fill_lite()),
Stroke::NONE,
StrokeKind::Outside,
);
let bg_idx = ui.painter().add(bg_shape.clone());
/// Draw title content. rect.min += egui::emath::vec2(6.0, 0.0);
fn title_ui(&self, ui: &mut egui::Ui) { rect.max -= egui::emath::vec2(6.0, 0.0);
let rect = ui.available_rect_before_wrap(); let resp = ui
.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
(add_content)(ui, self, cb);
})
.response;
// Create background shape. // Setup background size.
let mut bg_shape = RectShape { let bg_rect = {
rect, let mut r = resp.rect.clone();
rounding: Rounding { r.min -= egui::emath::vec2(6.0, 0.0);
nw: 8.0, r.max += egui::emath::vec2(6.0, 0.0);
ne: 8.0, r
sw: 0.0, };
se: 0.0, bg_shape.rect = bg_rect;
}, ui.painter().set(bg_idx, bg_shape);
fill: Colors::yellow(), }
stroke: Stroke::NONE, }
blur_width: 0.0,
fill_texture_id: Default::default(),
uv: Rect::ZERO
};
let bg_idx = ui.painter().add(bg_shape);
// Draw title content. /// Draw title content.
let title_resp = ui.allocate_ui_at_rect(rect, |ui| { fn title_ui(title: &String, ui: &mut egui::Ui) {
ui.vertical_centered_justified(|ui| { let rect = ui.available_rect_before_wrap();
ui.add_space(Self::DEFAULT_MARGIN + 1.0);
ui.label(RichText::new(self.title.as_ref().unwrap())
.size(19.0)
.color(Colors::title(true))
);
ui.add_space(Self::DEFAULT_MARGIN);
// Draw line below title.
View::horizontal_line(ui, Colors::item_stroke());
});
}).response;
// Setup background shape to be painted behind title content. // Create background shape.
bg_shape.rect = title_resp.rect; let mut bg_shape = RectShape::new(
ui.painter().set(bg_idx, bg_shape); rect,
} CornerRadius {
} nw: 8.0 as u8,
ne: 8.0 as u8,
sw: 0.0 as u8,
se: 0.0 as u8,
},
Colors::yellow(),
Stroke::NONE,
StrokeKind::Outside,
);
let bg_idx = ui.painter().add(bg_shape.clone());
// Draw title content.
let resp = ui
.vertical_centered(|ui| {
ui.add_space(Modal::DEFAULT_MARGIN + 2.0);
ui.label(RichText::new(title).size(19.0).color(Colors::title(true)));
ui.add_space(Modal::DEFAULT_MARGIN + 1.0);
// Draw line below title.
View::horizontal_line(ui, Colors::item_stroke());
})
.response;
// Setup background size.
bg_shape.rect = resp.rect;
ui.painter().set(bg_idx, bg_shape);
}
+352 -202
View File
@@ -12,235 +12,385 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::{Align, Layout, RichText, Rounding}; use eframe::epaint::RectShape;
use egui::{
Align, Color32, CornerRadius, CursorIcon, Layout, RichText, Sense, StrokeKind, UiBuilder,
};
use crate::AppConfig; use crate::AppConfig;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::{CARET_RIGHT, CHECK_CIRCLE, COMPUTER_TOWER, DOTS_THREE_CIRCLE, GLOBE_SIMPLE, PENCIL, PLUS_CIRCLE, POWER, TRASH, X_CIRCLE}; use crate::gui::icons::{
CHECK_CIRCLE, COMPUTER_TOWER, DOTS_THREE_CIRCLE, GLOBE_SIMPLE, PLUS_CIRCLE, POWER, QR_CODE,
TRASH, WARNING_CIRCLE, X_CIRCLE,
};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, View};
use crate::gui::views::network::modals::ExternalConnectionModal;
use crate::gui::views::network::NodeSetup; use crate::gui::views::network::NodeSetup;
use crate::gui::views::types::{ModalContainer, ModalPosition}; use crate::gui::views::network::modals::{ExternalConnectionModal, ShareConnectionContent};
use crate::gui::views::network::types::ShareConnection;
use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::{Modal, View};
use crate::node::{Node, NodeConfig}; use crate::node::{Node, NodeConfig};
use crate::wallet::{ConnectionsConfig, ExternalConnection}; use crate::wallet::{ConnectionsConfig, ExternalConnection};
/// Network connections content. /// Network connections content.
pub struct ConnectionsContent { pub struct ConnectionsContent {
/// External connection [`Modal`] content. /// Flag to check connections state on first draw.
ext_conn_modal: ExternalConnectionModal, first_draw: bool,
/// [`Modal`] identifiers allowed at this ui container. /// External connection [`Modal`] content.
modal_ids: Vec<&'static str> ext_conn_modal_content: ExternalConnectionModal,
/// [`Modal`] content to share connection with QR code.
share_conn_modal_content: Option<ShareConnectionContent>,
} }
impl Default for ConnectionsContent { impl Default for ConnectionsContent {
fn default() -> Self { fn default() -> Self {
ExternalConnection::check_ext_conn_availability(None); Self {
Self { first_draw: true,
ext_conn_modal: ExternalConnectionModal::new(None), ext_conn_modal_content: ExternalConnectionModal::new(None),
modal_ids: vec![ share_conn_modal_content: None,
ExternalConnectionModal::NETWORK_ID }
], }
}
}
} }
impl ModalContainer for ConnectionsContent { /// Identifier for [`Modal`] to share connection.
fn modal_ids(&self) -> &Vec<&'static str> { const SHARE_CONN_QR_MODAL: &'static str = "share_conn_qr_modal";
&self.modal_ids
}
fn modal_ui(&mut self, impl ContentContainer for ConnectionsContent {
ui: &mut egui::Ui, fn modal_ids(&self) -> Vec<&'static str> {
modal: &Modal, vec![ExternalConnectionModal::NETWORK_ID, SHARE_CONN_QR_MODAL]
cb: &dyn PlatformCallbacks) { }
match modal.id {
ExternalConnectionModal::NETWORK_ID => { fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
self.ext_conn_modal.ui(ui, cb, modal, |_| {}); match modal.id {
}, ExternalConnectionModal::NETWORK_ID => {
_ => {} self.ext_conn_modal_content.ui(ui, cb, modal, |_| {});
} }
} SHARE_CONN_QR_MODAL => {
if let Some(c) = self.share_conn_modal_content.as_mut() {
c.ui(ui, modal, cb);
}
}
_ => {}
}
}
fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) {
// Check connections state on first draw.
if self.first_draw {
ExternalConnection::check(None, ui.ctx());
self.first_draw = false;
}
ui.add_space(2.0);
// Show network type selection.
let saved_chain_type = AppConfig::chain_type();
NodeSetup::chain_type_ui(ui);
ui.add_space(6.0);
// Check connections availability.
if saved_chain_type != AppConfig::chain_type() {
ExternalConnection::check(None, ui.ctx());
}
// Show integrated node info content.
Self::integrated_node_item_ui(
ui,
Colors::fill_lite(),
(true, || {
AppConfig::toggle_show_connections_network_panel();
}),
|ui| {
let r = View::item_rounding(0, 1, true);
View::item_button(ui, r, QR_CODE, None, || {
let (api_address, api_port) = NodeConfig::get_api_address();
if let Ok(c) = ShareConnectionContent::new(ShareConnection {
url: format!("http://{}:{}", api_address, api_port),
username: "grin".to_string(),
secret: NodeConfig::get_api_secret(true).unwrap_or("".to_string()),
}) {
self.share_conn_modal_content = Some(c);
// Show QR code to share integrated node connection.
Modal::new(SHARE_CONN_QR_MODAL)
.position(ModalPosition::Center)
.title(t!("network.node"))
.show();
}
});
true
},
);
// Show external connections.
ui.add_space(8.0);
ui.label(
RichText::new(t!("wallets.ext_conn"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
// Show button to add new external node connection.
let add_node_text = format!("{} {}", PLUS_CIRCLE, t!("wallets.add_node"));
View::button(ui, add_node_text, Colors::white_or_black(false), || {
self.show_add_ext_conn_modal(None);
});
ui.add_space(4.0);
let ext_conn_list = ConnectionsConfig::ext_conn_list();
let len = ext_conn_list.len();
if len != 0 {
ui.add_space(8.0);
for (i, c) in ext_conn_list.iter().enumerate() {
ui.horizontal_wrapped(|ui| {
let mut show_qr_content: Option<ShareConnectionContent> = None;
// Draw external connection list item.
let bg = Colors::fill_lite();
Self::ext_conn_item_ui(
ui,
bg,
c,
i,
len,
(true, || {
self.show_add_ext_conn_modal(Some(c.clone()));
}),
|ui| {
// Draw button to delete connection.
let r = View::item_rounding(i, len, true);
View::item_button(ui, r, TRASH, Some(Colors::inactive_text()), || {
ConnectionsConfig::remove_ext_conn(c.id);
});
// Draw button to share connection
let r = CornerRadius::default();
View::item_button(ui, r, QR_CODE, None, || {
if let Ok(c) = ShareConnectionContent::new(ShareConnection {
url: c.url.clone(),
username: "grin".to_string(),
secret: c.secret.clone().unwrap_or("".to_string()),
}) {
show_qr_content = Some(c);
}
});
},
);
if let Some(c) = show_qr_content {
self.share_conn_modal_content = Some(c);
// Show QR code to share external connection.
Modal::new(SHARE_CONN_QR_MODAL)
.position(ModalPosition::Center)
.title(t!("wallets.ext_conn").replace(":", ""))
.show();
}
});
}
}
}
} }
impl ConnectionsContent { impl ConnectionsContent {
/// Draw connections content. /// Draw integrated node connection item content.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { pub fn integrated_node_item_ui(
self.current_modal_ui(ui, cb); ui: &mut egui::Ui,
bg: Color32,
on_click: (bool, impl FnOnce()),
custom_button: impl FnOnce(&mut egui::Ui) -> bool,
) {
// Draw round background.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(78.0);
let r = View::item_rounding(0, 1, false);
let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside);
let bg_idx = ui.painter().add(bg_shape.clone());
ui.add_space(2.0); let res = ui
.scope_builder(
UiBuilder::new()
.sense(Sense::click())
.layout(Layout::right_to_left(Align::Center))
.max_rect(rect),
|ui| {
// Draw custom button.
let extra_button = custom_button(ui);
// Show network type selection. // Draw buttons to start/stop node.
let saved_chain_type = AppConfig::chain_type(); if Node::get_error().is_none() {
NodeSetup::chain_type_ui(ui); let rounding = if extra_button {
ui.add_space(6.0); CornerRadius::default()
} else {
View::item_rounding(0, 1, true)
};
if !Node::is_running() {
View::item_button(ui, rounding, POWER, Some(Colors::green()), || {
Node::start();
});
} else if !Node::is_starting()
&& !Node::is_stopping()
&& !Node::is_restarting()
{
View::item_button(ui, rounding, POWER, Some(Colors::red()), || {
Node::stop(false);
});
}
}
// Check connections availability. let layout_size = ui.available_size();
if saved_chain_type != AppConfig::chain_type() { ui.allocate_ui_with_layout(
ExternalConnection::check_ext_conn_availability(None); layout_size,
} Layout::left_to_right(Align::Center),
|ui| {
ui.add_space(6.0);
ui.vertical(|ui| {
ui.add_space(3.0);
ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
ui.add_space(1.0);
ui.label(
RichText::new(t!("network.node"))
.size(18.0)
.color(Colors::title(false)),
);
});
// Show integrated node info content. // Setup node status text.
Self::integrated_node_item_ui(ui, |ui| { let has_error = Node::get_error().is_some();
// Draw button to show integrated node info. let status_icon = if has_error {
View::item_button(ui, View::item_rounding(0, 1, true), CARET_RIGHT, None, || { WARNING_CIRCLE
AppConfig::toggle_show_connections_network_panel(); } else if !Node::is_running() {
}); X_CIRCLE
}); } else if Node::not_syncing() {
CHECK_CIRCLE
} else {
DOTS_THREE_CIRCLE
};
let status_text = format!(
"{} {}",
status_icon,
if has_error {
t!("error").into()
} else {
Node::get_sync_status_text()
}
);
View::ellipsize_text(ui, status_text, 15.0, Colors::text(false));
ui.add_space(1.0);
// Show external connections. // Setup node API address text.
ui.add_space(8.0); let (api_address, api_port) = NodeConfig::get_api_address();
ui.label(RichText::new(t!("wallets.ext_conn")).size(16.0).color(Colors::gray())); let address_text = format!(
ui.add_space(6.0); "{} http://{}:{}",
COMPUTER_TOWER, api_address, api_port
);
ui.label(
RichText::new(address_text).size(15.0).color(Colors::gray()),
);
})
},
);
},
)
.response;
let (clickable, on_click) = on_click;
let clicked = res.clicked() || res.long_touched();
// Setup background and cursor.
if clickable && res.hovered() {
res.on_hover_cursor(CursorIcon::PointingHand);
bg_shape.fill = Colors::fill();
}
ui.painter().set(bg_idx, bg_shape);
// Handle clicks on layout.
if clicked && clickable {
on_click();
}
}
// Show button to add new external node connection. /// Draw external connection item content.
let add_node_text = format!("{} {}", PLUS_CIRCLE, t!("wallets.add_node")); pub fn ext_conn_item_ui(
View::button(ui, add_node_text, Colors::white_or_black(false), || { ui: &mut egui::Ui,
self.show_add_ext_conn_modal(None, cb); bg: Color32,
}); conn: &ExternalConnection,
index: usize,
len: usize,
on_click: (bool, impl FnOnce()),
custom_button: impl FnOnce(&mut egui::Ui),
) {
// Draw round background.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(52.0);
let r = View::item_rounding(index, len, false);
let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside);
let bg_idx = ui.painter().add(bg_shape.clone());
ui.add_space(4.0); let res = ui
.scope_builder(
UiBuilder::new()
.sense(Sense::click())
.layout(Layout::right_to_left(Align::Center))
.max_rect(rect),
|ui| {
// Draw custom button.
custom_button(ui);
let ext_conn_list = ConnectionsConfig::ext_conn_list(); let layout_size = ui.available_size();
if !ext_conn_list.is_empty() { ui.allocate_ui_with_layout(
ui.add_space(8.0); layout_size,
for (index, conn) in ext_conn_list.iter().enumerate() { Layout::left_to_right(Align::Center),
ui.horizontal_wrapped(|ui| { |ui| {
// Draw connection list item. ui.add_space(6.0);
let len = ext_conn_list.len(); ui.vertical(|ui| {
Self::ext_conn_item_ui(ui, conn, index, len, |ui| { // Draw connections URL.
// Draw buttons for non-default connections. ui.add_space(4.0);
if conn.url != ExternalConnection::DEFAULT_MAIN_URL { let conn_text = format!("{} {}", GLOBE_SIMPLE, conn.url);
let button_rounding = View::item_rounding(index, len, true); View::ellipsize_text(ui, conn_text, 15.0, Colors::title(false));
View::item_button(ui, button_rounding, TRASH, None, || { ui.add_space(1.0);
ConnectionsConfig::remove_ext_conn(conn.id);
});
View::item_button(ui, Rounding::default(), PENCIL, None, || {
self.show_add_ext_conn_modal(Some(conn.clone()), cb);
});
}
});
});
}
}
}
/// Draw integrated node connection item content. // Setup connection status text.
pub fn integrated_node_item_ui(ui: &mut egui::Ui, custom_button: impl FnOnce(&mut egui::Ui)) { let status_text = if let Some(available) = conn.available {
// Draw round background. if available {
let mut rect = ui.available_rect_before_wrap(); format!("{} {}", CHECK_CIRCLE, t!("network.available"))
rect.set_height(78.0); } else {
let rounding = View::item_rounding(0, 1, false); format!("{} {}", X_CIRCLE, t!("network.not_available"))
ui.painter().rect(rect, rounding, Colors::fill(), View::item_stroke()); }
} else {
format!(
"{} {}",
DOTS_THREE_CIRCLE,
t!("network.availability_check")
)
};
ui.label(
RichText::new(status_text).size(15.0).color(Colors::gray()),
);
ui.add_space(3.0);
});
},
);
},
)
.response;
let (clickable, on_click) = on_click;
let clicked = res.clicked() || res.long_touched();
// Setup background and cursor.
if clickable && res.hovered() {
res.on_hover_cursor(CursorIcon::PointingHand);
bg_shape.fill = Colors::fill();
}
ui.painter().set(bg_idx, bg_shape);
// Handle clicks on layout.
if clicked && clickable {
on_click();
}
}
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| { /// Show [`Modal`] to add external connection.
// Draw custom button. pub fn show_add_ext_conn_modal(&mut self, conn: Option<ExternalConnection>) {
custom_button(ui); self.ext_conn_modal_content = ExternalConnectionModal::new(conn);
// Show modal.
if !Node::is_running() { Modal::new(ExternalConnectionModal::NETWORK_ID)
// Draw button to start integrated node. .position(ModalPosition::CenterTop)
View::item_button(ui, Rounding::default(), POWER, Some(Colors::green()), || { .title(t!("wallets.add_node"))
Node::start(); .show();
}); }
} else if !Node::is_starting() && !Node::is_stopping() && !Node::is_restarting() { }
// Draw button to stop integrated node.
View::item_button(ui, Rounding::default(), POWER, Some(Colors::red()), || {
Node::stop(false);
});
}
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| {
ui.add_space(6.0);
ui.vertical(|ui| {
ui.add_space(3.0);
ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
ui.add_space(1.0);
ui.label(RichText::new(t!("network.node"))
.size(18.0)
.color(Colors::title(false)));
});
// Setup node status text.
let status_icon = if !Node::is_running() {
X_CIRCLE
} else if Node::not_syncing() {
CHECK_CIRCLE
} else {
DOTS_THREE_CIRCLE
};
let status_text = format!("{} {}", status_icon, Node::get_sync_status_text());
ui.label(RichText::new(status_text).size(15.0).color(Colors::text(false)));
ui.add_space(1.0);
// Setup node API address text.
let api_address = NodeConfig::get_api_address();
let address_text = format!("{} http://{}", COMPUTER_TOWER, api_address);
ui.label(RichText::new(address_text).size(15.0).color(Colors::gray()));
})
});
});
}
/// Draw external connection item content.
pub fn ext_conn_item_ui(ui: &mut egui::Ui,
conn: &ExternalConnection,
index: usize,
len: usize,
buttons_ui: impl FnOnce(&mut egui::Ui)) {
// Setup layout size.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(52.0);
// Draw round background.
let bg_rect = rect.clone();
let item_rounding = View::item_rounding(index, len, false);
ui.painter().rect(bg_rect, item_rounding, Colors::fill(), View::item_stroke());
ui.vertical(|ui| {
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| {
// Draw provided buttons.
buttons_ui(ui);
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| {
ui.add_space(6.0);
ui.vertical(|ui| {
// Draw connections URL.
ui.add_space(4.0);
let conn_text = format!("{} {}", GLOBE_SIMPLE, conn.url);
View::ellipsize_text(ui, conn_text, 15.0, Colors::title(false));
ui.add_space(1.0);
// Setup connection status text.
let status_text = if let Some(available) = conn.available {
if available {
format!("{} {}", CHECK_CIRCLE, t!("network.available"))
} else {
format!("{} {}", X_CIRCLE, t!("network.not_available"))
}
} else {
format!("{} {}", DOTS_THREE_CIRCLE, t!("network.availability_check"))
};
ui.label(RichText::new(status_text).size(15.0).color(Colors::gray()));
ui.add_space(3.0);
});
});
});
});
}
/// Show [`Modal`] to add external connection.
pub fn show_add_ext_conn_modal(&mut self,
conn: Option<ExternalConnection>,
cb: &dyn PlatformCallbacks) {
self.ext_conn_modal = ExternalConnectionModal::new(conn);
// Show modal.
Modal::new(ExternalConnectionModal::NETWORK_ID)
.position(ModalPosition::CenterTop)
.title(t!("wallets.add_node"))
.show();
cb.show_keyboard();
}
}
+404 -295
View File
@@ -12,326 +12,435 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use std::time::Duration;
use egui::{Id, Margin, RichText, ScrollArea};
use egui::scroll_area::ScrollBarVisibility; use egui::scroll_area::ScrollBarVisibility;
use egui::{Id, Margin, RichText, ScrollArea};
use crate::AppConfig; use crate::AppConfig;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::{ARROWS_COUNTER_CLOCKWISE, BRIEFCASE, DATABASE, DOTS_THREE_OUTLINE_VERTICAL, FACTORY, FADERS, GAUGE, POWER}; use crate::gui::icons::{
ARROW_LEFT, ARROWS_COUNTER_CLOCKWISE, BRIEFCASE, DATABASE, DOTS_THREE_OUTLINE_VERTICAL,
FACTORY, FADERS, GAUGE, GEAR, GLOBE, POWER,
};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::network::types::{NodeTab, NodeTabType};
use crate::gui::views::network::{
ConnectionsContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings,
};
use crate::gui::views::settings::SettingsContent;
use crate::gui::views::types::{ContentContainer, LinePosition, TitleContentType, TitleType};
use crate::gui::views::{Content, TitlePanel, View}; use crate::gui::views::{Content, TitlePanel, View};
use crate::gui::views::network::{ConnectionsContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings}; use crate::node::{Node, NodeConfig, NodeError};
use crate::gui::views::network::types::{NetworkTab, NetworkTabType};
use crate::gui::views::types::{TitleContentType, TitleType};
use crate::node::{Node, NodeError};
use crate::wallet::ExternalConnection;
/// Network content. /// Network content.
pub struct NetworkContent { pub struct NetworkContent {
/// Current integrated node tab content. /// Current integrated node tab content.
node_tab_content: Box<dyn NetworkTab>, node_tab_content: Box<dyn NodeTab>,
/// Connections content. /// Connections content.
connections: ConnectionsContent, connections: ConnectionsContent,
/// Application settings content.
settings_content: Option<SettingsContent>,
} }
impl Default for NetworkContent { impl Default for NetworkContent {
fn default() -> Self { fn default() -> Self {
Self { Self {
node_tab_content: Box::new(NetworkNode::default()), node_tab_content: Box::new(NetworkNode::default()),
connections: ConnectionsContent::default(), connections: ConnectionsContent::default(),
} settings_content: None,
} }
}
} }
impl NetworkContent { impl NetworkContent {
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let show_connections = AppConfig::show_connections_network_panel(); let show_settings = self.showing_settings();
let dual_panel = Content::is_dual_panel_mode(ui); let show_connections = AppConfig::show_connections_network_panel();
let dual_panel = Content::is_dual_panel_mode(ui.ctx());
// Show title panel. // Show title panel.
self.title_ui(ui, show_connections); self.title_ui(ui, dual_panel, show_connections);
// Show integrated node tabs content. // Show integrated node tabs content.
if !show_connections { if !show_connections && !show_settings {
egui::TopBottomPanel::bottom("node_tabs_content") let side_padding = View::TAB_ITEMS_PADDING + if View::is_desktop() { 0.0 } else { 4.0 };
.min_height(0.5) let tabs_margin = Margin {
.resizable(false) left: (View::get_left_inset() + side_padding) as i8,
.frame(egui::Frame { right: (View::far_right_inset_margin(ui) + side_padding) as i8,
inner_margin: Margin { top: View::TAB_ITEMS_PADDING as i8,
left: View::get_left_inset() + View::TAB_ITEMS_PADDING, bottom: (View::get_bottom_inset() + View::TAB_ITEMS_PADDING) as i8,
right: View::far_right_inset_margin(ui) + View::TAB_ITEMS_PADDING, };
top: View::TAB_ITEMS_PADDING, egui::TopBottomPanel::bottom("network_tabs_content")
bottom: View::get_bottom_inset() + View::TAB_ITEMS_PADDING, .min_height(0.5)
}, .resizable(false)
fill: Colors::fill(), .frame(egui::Frame {
..Default::default() inner_margin: tabs_margin,
}) fill: Colors::fill(),
.show_inside(ui, |ui| { ..Default::default()
ui.vertical_centered(|ui| { })
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { .show_inside(ui, |ui| {
self.tabs_ui(ui); let rect = ui.available_rect_before_wrap();
}); View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
}); self.tabs_ui(ui);
}); });
} // Draw content divider line.
let r = {
let mut r = rect.clone();
r.min.x -= tabs_margin.left as f32;
r.min.y -= tabs_margin.top as f32;
r.max.x += tabs_margin.right as f32;
r
};
View::line(ui, LinePosition::TOP, &r, Colors::stroke());
});
}
// Show current node tab content. // Show settings or integrated node content.
egui::SidePanel::right("node_tab_content") egui::SidePanel::right("network_side_content")
.resizable(false) .resizable(false)
.exact_width(ui.available_width()) .exact_width(ui.available_width())
.frame(egui::Frame { .frame(egui::Frame {
..Default::default() ..Default::default()
}) })
.show_animated_inside(ui, !show_connections, |ui| { .show_animated_inside(ui, show_settings || !show_connections, |ui| {
egui::CentralPanel::default() egui::CentralPanel::default()
.frame(egui::Frame { .frame(egui::Frame {
fill: Colors::white_or_black(false), inner_margin: Margin {
stroke: View::item_stroke(), left: (View::get_left_inset() + View::content_padding()) as i8,
inner_margin: Margin { right: (View::far_right_inset_margin(ui) + View::content_padding())
left: View::get_left_inset() + 4.0, as i8,
right: View::far_right_inset_margin(ui) + 4.0, top: 3.0 as i8,
top: 3.0, bottom: 4.0 as i8,
bottom: 4.0, },
}, ..Default::default()
..Default::default() })
}) .show_inside(ui, |ui| {
.show_inside(ui, |ui| { let rect = ui.available_rect_before_wrap();
self.node_tab_content.ui(ui, cb); if let Some(c) = &mut self.settings_content {
}); ScrollArea::vertical()
}); .id_salt("app_settings_network")
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(1.0);
ui.vertical_centered(|ui| {
// Show application settings content.
View::max_width_ui(
ui,
Content::SIDE_PANEL_WIDTH * 1.3,
|ui| {
c.ui(ui, cb);
},
);
});
});
} else if self.node_tab_content.get_type() != NodeTabType::Settings {
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
let node_err = Node::get_error();
if let Some(err) = node_err {
node_error_ui(ui, err);
} else if !Node::is_running() {
disabled_node_ui(ui);
} else if Node::get_stats().is_none()
|| Node::is_restarting() || Node::is_stopping()
{
NetworkContent::loading_ui(ui, None::<String>);
} else {
self.node_tab_content.tab_ui(ui, cb);
}
});
} else {
self.node_tab_content.tab_ui(ui, cb);
}
// Draw content divider line.
let r = {
let mut r = rect.clone();
r.min.y -= 3.0;
r.max.x += View::content_padding();
r.max.y += View::content_padding();
r
};
if dual_panel {
View::line(ui, LinePosition::RIGHT, &r, Colors::item_stroke());
}
});
});
// Show connections content. // Show connections content.
egui::CentralPanel::default() egui::CentralPanel::default()
.frame(egui::Frame { .frame(egui::Frame {
stroke: View::item_stroke(), inner_margin: Margin {
inner_margin: Margin { left: if show_connections {
left: if show_connections { View::get_left_inset() + View::content_padding()
View::get_left_inset() + 4.0 } else {
} else { 0.0
0.0 } as i8,
}, right: if show_connections {
right: if show_connections { View::far_right_inset_margin(ui) + View::content_padding()
View::far_right_inset_margin(ui) + 4.0 } else {
} else { 0.0
0.0 } as i8,
}, top: 3.0 as i8,
top: 3.0, bottom: 0.0 as i8,
bottom: if View::is_desktop() && show_connections { },
6.0 ..Default::default()
} else { })
4.0 .show_inside(ui, |ui| {
}, let rect = ui.available_rect_before_wrap();
}, ScrollArea::vertical()
fill: Colors::button(), .id_salt("connections_scroll")
..Default::default() .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
}) .auto_shrink([false; 2])
.show_inside(ui, |ui| { .show(ui, |ui| {
ScrollArea::vertical() ui.add_space(1.0);
.id_source("connections_content") ui.vertical_centered(|ui| {
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) let max_width = if !dual_panel {
.auto_shrink([false; 2]) Content::SIDE_PANEL_WIDTH * 1.3
.show(ui, |ui| { } else {
ui.add_space(1.0); ui.available_width()
ui.vertical_centered(|ui| { };
let max_width = if !dual_panel { View::max_width_ui(ui, max_width, |ui| {
Content::SIDE_PANEL_WIDTH * 1.3 self.connections.ui(ui, cb);
} else { });
ui.available_width() });
}; ui.add_space(32.0);
View::max_width_ui(ui, max_width, |ui| { });
self.connections.ui(ui, cb); // Draw content divider line.
}); let r = {
}); let mut r = rect.clone();
}); r.min.y -= 3.0;
}); r.max.x += View::content_padding();
r.max.y += View::content_padding() + View::get_bottom_inset();
r
};
if show_connections && dual_panel {
View::line(ui, LinePosition::RIGHT, &r, Colors::item_stroke());
}
});
// Redraw after delay. // Redraw after delay if node is running at non-dual-panel mode.
if Node::is_running() { if ((!dual_panel && Content::is_network_panel_open()) || dual_panel) && Node::is_running() {
ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY); ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY);
} else if show_connections { }
ui.ctx().request_repaint_after(Duration::from_millis(1000)); }
}
}
/// Draw tab buttons in the bottom of the screen. /// Navigate back, return `true` if action was not consumed.
fn tabs_ui(&mut self, ui: &mut egui::Ui) { pub fn on_back(&mut self) -> bool {
ui.vertical_centered(|ui| { if self.showing_settings() {
// Setup spacing between tabs. // Close settings.
ui.style_mut().spacing.item_spacing = egui::vec2(View::TAB_ITEMS_PADDING, 0.0); self.settings_content = None;
// Setup vertical padding inside tab button. return false;
ui.style_mut().spacing.button_padding = egui::vec2(0.0, 4.0); }
true
}
// Draw tab buttons. /// Check if application settings content is showing.
let current_type = self.node_tab_content.get_type(); pub fn showing_settings(&self) -> bool {
ui.columns(4, |columns| { self.settings_content.is_some()
columns[0].vertical_centered_justified(|ui| { }
View::tab_button(ui, DATABASE, current_type == NetworkTabType::Node, || {
self.node_tab_content = Box::new(NetworkNode::default());
});
});
columns[1].vertical_centered_justified(|ui| {
View::tab_button(ui, GAUGE, current_type == NetworkTabType::Metrics, || {
self.node_tab_content = Box::new(NetworkMetrics::default());
});
});
columns[2].vertical_centered_justified(|ui| {
View::tab_button(ui, FACTORY, current_type == NetworkTabType::Mining, || {
self.node_tab_content = Box::new(NetworkMining::default());
});
});
columns[3].vertical_centered_justified(|ui| {
View::tab_button(ui, FADERS, current_type == NetworkTabType::Settings, || {
self.node_tab_content = Box::new(NetworkSettings::default());
});
});
});
});
}
/// Draw title content. /// Draw tab buttons at bottom of the screen.
fn title_ui(&mut self, ui: &mut egui::Ui, show_connections: bool) { fn tabs_ui(&mut self, ui: &mut egui::Ui) {
// Setup values for title panel. ui.vertical_centered(|ui| {
let title_text = self.node_tab_content.get_type().title().to_uppercase(); // Setup spacing between tabs.
let subtitle_text = Node::get_sync_status_text(); ui.style_mut().spacing.item_spacing = egui::vec2(View::TAB_ITEMS_PADDING, 0.0);
let not_syncing = Node::not_syncing();
let title_content = if !show_connections {
TitleContentType::WithSubTitle(title_text, subtitle_text, !not_syncing)
} else {
TitleContentType::Title(t!("network.connections").to_uppercase())
};
// Draw title panel. // Draw tab buttons.
TitlePanel::new(Id::from("network_title_panel")).ui(TitleType::Single(title_content), |ui| { let current_type = self.node_tab_content.get_type();
if !show_connections { ui.columns(4, |columns| {
View::title_button_big(ui, DOTS_THREE_OUTLINE_VERTICAL, |_| { columns[0].vertical_centered_justified(|ui| {
AppConfig::toggle_show_connections_network_panel(); let active = Some(current_type == NodeTabType::Info);
if AppConfig::show_connections_network_panel() { View::tab_button(ui, DATABASE, None, active, |_| {
ExternalConnection::check_ext_conn_availability(None); self.node_tab_content = Box::new(NetworkNode::default());
} });
}); });
} columns[1].vertical_centered_justified(|ui| {
}, |ui| { let active = Some(current_type == NodeTabType::Metrics);
if !Content::is_dual_panel_mode(ui) { View::tab_button(ui, GAUGE, None, active, |_| {
View::title_button_big(ui, BRIEFCASE, |_| { self.node_tab_content = Box::new(NetworkMetrics::default());
Content::toggle_network_panel(); });
}); });
} columns[2].vertical_centered_justified(|ui| {
}, ui); let active = Some(current_type == NodeTabType::Mining);
} View::tab_button(ui, FACTORY, None, active, |_| {
self.node_tab_content = Box::new(NetworkMining::default());
});
});
columns[3].vertical_centered_justified(|ui| {
let active = Some(current_type == NodeTabType::Settings);
View::tab_button(ui, FADERS, None, active, |_| {
self.node_tab_content = Box::new(NetworkSettings::default());
});
});
});
});
}
/// Content to draw when node is disabled. /// Draw title content.
pub fn disabled_node_ui(ui: &mut egui::Ui) { fn title_ui(&mut self, ui: &mut egui::Ui, dual_panel: bool, show_connections: bool) {
View::center_content(ui, 156.0, |ui| { let show_settings = self.showing_settings();
let text = t!("network.disabled_server", "dots" => DOTS_THREE_OUTLINE_VERTICAL);
ui.label(RichText::new(text)
.size(16.0)
.color(Colors::inactive_text())
);
ui.add_space(8.0);
View::action_button(ui, format!("{} {}", POWER, t!("network.enable_node")), || {
Node::start();
});
ui.add_space(2.0);
Self::autorun_node_ui(ui);
});
}
/// Content to draw on loading. // Setup values for title panel.
pub fn loading_ui(ui: &mut egui::Ui, text: Option<String>) { let title_text = self.node_tab_content.get_type().title();
match text { let subtitle_text = Node::get_sync_status_text().into();
None => { let not_syncing = Node::not_syncing() && !Node::data_dir_changing();
ui.centered_and_justified(|ui| { let title_content = if show_settings {
View::big_loading_spinner(ui); TitleContentType::Title(t!("settings").into())
}); } else if !show_connections {
} TitleContentType::WithSubTitle(title_text, subtitle_text, !not_syncing)
Some(t) => { } else {
View::center_content(ui, 162.0, |ui| { TitleContentType::Title(t!("network.connections").into())
View::big_loading_spinner(ui); };
ui.add_space(18.0);
ui.label(RichText::new(t)
.size(16.0)
.color(Colors::inactive_text())
);
});
}
}
}
/// Draw checkbox to run integrated node on application launch. // Draw title panel.
pub fn autorun_node_ui(ui: &mut egui::Ui) { TitlePanel::new(Id::from("network_title_panel")).ui(
let autostart = AppConfig::autostart_node(); TitleType::Single(title_content),
View::checkbox(ui, autostart, t!("network.autorun"), || { |ui| {
AppConfig::toggle_node_autostart(); if show_settings {
}); View::title_button_big(ui, ARROW_LEFT, |_| {
} self.settings_content = None;
});
} else if !show_connections {
View::title_button_big(ui, GLOBE, |_| {
AppConfig::toggle_show_connections_network_panel();
});
} else if !dual_panel {
View::title_button_big(ui, GEAR, |_| {
self.settings_content = Some(SettingsContent::default());
});
}
},
|ui| {
if !dual_panel && !show_settings {
View::title_button_big(ui, BRIEFCASE, |_| {
Content::toggle_network_panel();
});
}
},
ui,
);
}
/// Draw integrated node error content. /// Content to draw on loading.
pub fn node_error_ui(ui: &mut egui::Ui, e: NodeError) { pub fn loading_ui(ui: &mut egui::Ui, text: Option<impl Into<String>>) {
match e { match text {
NodeError::Storage => { None => {
View::center_content(ui, 156.0, |ui| { ui.centered_and_justified(|ui| {
ui.label(RichText::new(t!("network_node.error_clean")) View::big_loading_spinner(ui);
.size(16.0) });
.color(Colors::red()) }
); Some(t) => {
ui.add_space(8.0); View::center_content(ui, 162.0, |ui| {
let btn_txt = format!("{} {}", View::big_loading_spinner(ui);
ARROWS_COUNTER_CLOCKWISE, ui.add_space(18.0);
t!("network_node.resync")); ui.label(RichText::new(t).size(16.0).color(Colors::inactive_text()));
View::action_button(ui, btn_txt, || { });
Node::clean_up_data(); }
Node::start(); }
}); }
ui.add_space(2.0);
}); /// Draw checkbox to run integrated node on application launch.
return; pub fn autorun_node_ui(ui: &mut egui::Ui) {
} let autostart = AppConfig::autostart_node();
NodeError::P2P | NodeError::API => { View::checkbox(ui, autostart, t!("network.autorun"), || {
let msg_type = match e { AppConfig::toggle_node_autostart();
NodeError::API => "API", });
_ => "P2P" }
}; }
View::center_content(ui, 106.0, |ui| {
let text = t!( /// Content to draw when node is disabled.
"network_node.error_p2p_api", fn disabled_node_ui(ui: &mut egui::Ui) {
"p2p_api" => msg_type, View::center_content(ui, 156.0, |ui| {
"settings" => FADERS let text = t!("network.disabled_server", "dots" => DOTS_THREE_OUTLINE_VERTICAL);
); ui.label(
ui.label(RichText::new(text) RichText::new(text)
.size(16.0) .size(16.0)
.color(Colors::red()) .color(Colors::inactive_text()),
); );
ui.add_space(2.0); ui.add_space(8.0);
}); View::action_button(
return; ui,
} format!("{} {}", POWER, t!("network.enable_node")),
NodeError::Configuration => { || {
View::center_content(ui, 106.0, |ui| { Node::start();
ui.label(RichText::new(t!("network_node.error_config", "settings" => FADERS)) },
.size(16.0) );
.color(Colors::red()) ui.add_space(2.0);
); NetworkContent::autorun_node_ui(ui);
ui.add_space(2.0); });
}); }
}
NodeError::Unknown => { /// Draw integrated node error content.
View::center_content(ui, 156.0, |ui| { pub fn node_error_ui(ui: &mut egui::Ui, e: NodeError) {
ui.label(RichText::new(t!("network_node.error_unknown", "settings" => FADERS)) match e {
.size(16.0) NodeError::Storage => {
.color(Colors::red()) View::center_content(ui, 156.0, |ui| {
); ui.label(
ui.add_space(8.0); RichText::new(t!("network_node.error_clean"))
let btn_txt = format!("{} {}", .size(16.0)
ARROWS_COUNTER_CLOCKWISE, .color(Colors::red()),
t!("network_node.resync")); );
View::action_button(ui, btn_txt, || { ui.add_space(8.0);
Node::clean_up_data(); let btn_txt = format!("{} {}", ARROWS_COUNTER_CLOCKWISE, t!("network_node.resync"));
Node::start(); View::action_button(ui, btn_txt, || {
}); Node::clean_up_data();
ui.add_space(2.0); Node::start();
}); });
} ui.add_space(2.0);
} });
} return;
} }
NodeError::P2P | NodeError::API => {
let msg_type = match e {
NodeError::API => "API",
_ => "P2P",
};
View::center_content(ui, 106.0, |ui| {
let text = t!(
"network_node.error_p2p_api",
"p2p_api" => msg_type,
"settings" => FADERS
);
ui.label(RichText::new(text).size(16.0).color(Colors::red()));
ui.add_space(2.0);
});
return;
}
NodeError::Configuration => {
View::center_content(ui, 106.0, |ui| {
ui.label(
RichText::new(t!("network_node.error_config", "settings" => FADERS))
.size(16.0)
.color(Colors::red()),
);
ui.add_space(8.0);
let btn_txt = format!(
"{} {}",
ARROWS_COUNTER_CLOCKWISE,
t!("network_settings.reset")
);
View::action_button(ui, btn_txt, || {
NodeConfig::reset_to_default();
Node::start();
});
ui.add_space(2.0);
});
}
NodeError::Unknown => {
View::center_content(ui, 156.0, |ui| {
ui.label(
RichText::new(t!("network_node.error_unknown", "settings" => FADERS))
.size(16.0)
.color(Colors::red()),
);
ui.add_space(8.0);
let btn_txt = format!("{} {}", ARROWS_COUNTER_CLOCKWISE, t!("network_node.resync"));
View::action_button(ui, btn_txt, || {
Node::clean_up_data();
Node::start();
});
ui.add_space(2.0);
});
}
}
}
+164 -171
View File
@@ -12,204 +12,197 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::{RichText, Rounding, ScrollArea, vec2};
use egui::scroll_area::ScrollBarVisibility; use egui::scroll_area::ScrollBarVisibility;
use egui::{CornerRadius, RichText, ScrollArea, StrokeKind, vec2};
use grin_core::consensus::{DAY_HEIGHT, GRIN_BASE, HOUR_SEC, REWARD};
use grin_servers::{DiffBlock, ServerStats}; use grin_servers::{DiffBlock, ServerStats};
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::{AT, COINS, CUBE_TRANSPARENT, HOURGLASS_LOW, HOURGLASS_MEDIUM, TIMER}; use crate::gui::icons::{AT, COINS, CUBE_TRANSPARENT, HOURGLASS_LOW, HOURGLASS_MEDIUM, TIMER};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Content, View};
use crate::gui::views::network::NetworkContent; use crate::gui::views::network::NetworkContent;
use crate::gui::views::network::types::{NetworkTab, NetworkTabType}; use crate::gui::views::network::types::{NodeTab, NodeTabType};
use crate::gui::views::{Content, View};
use crate::node::Node; use crate::node::Node;
/// Chain metrics tab content. /// Chain metrics tab content.
#[derive(Default)] #[derive(Default)]
pub struct NetworkMetrics; pub struct NetworkMetrics;
const BLOCK_REWARD: f64 = 60.0; const BLOCK_REWARD: u64 = REWARD / GRIN_BASE;
// 1 year is calculated as 365 days and 6 hours (31557600). // 1 year as 365 days and 6 hours (31557600).
const YEARLY_SUPPLY: f64 = ((60 * 60 * 24 * 365) + 6 * 60 * 60) as f64; const YEARLY_SUPPLY: u64 = (BLOCK_REWARD * DAY_HEIGHT * 365) + 6 * HOUR_SEC;
impl NetworkTab for NetworkMetrics { impl NodeTab for NetworkMetrics {
fn get_type(&self) -> NetworkTabType { fn get_type(&self) -> NodeTabType {
NetworkTabType::Metrics NodeTabType::Metrics
} }
fn ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { fn tab_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) {
// Show an error content when available. let server_stats = Node::get_stats();
let node_err = Node::get_error(); let stats = server_stats.as_ref().unwrap();
if node_err.is_some() { if stats.diff_stats.height == 0 {
NetworkContent::node_error_ui(ui, node_err.unwrap()); NetworkContent::loading_ui(ui, Some(t!("network_metrics.loading")));
return; return;
} }
ui.add_space(1.0);
// Show message to enable node when it's not running. View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
if !Node::is_running() { // Show emission and difficulty info.
NetworkContent::disabled_node_ui(ui); info_ui(ui, stats);
return; // Show difficulty adjustment window blocks.
} blocks_ui(ui, stats);
});
// Show loading spinner when node is stopping. }
if Node::is_stopping() {
NetworkContent::loading_ui(ui, None);
return;
}
// Show message when metrics are not available.
let server_stats = Node::get_stats();
if server_stats.is_none() || Node::is_restarting()
|| server_stats.as_ref().unwrap().diff_stats.height == 0 {
NetworkContent::loading_ui(ui, Some(t!("network_metrics.loading")));
return;
}
ui.add_space(1.0);
ui.vertical_centered(|ui| {
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
let stats = server_stats.as_ref().unwrap();
// Show emission and difficulty info.
info_ui(ui, stats);
// Show difficulty adjustment window blocks.
blocks_ui(ui, stats);
});
});
}
} }
const BLOCK_ITEM_HEIGHT: f32 = 78.0;
/// Draw emission and difficulty info. /// Draw emission and difficulty info.
fn info_ui(ui: &mut egui::Ui, stats: &ServerStats) { fn info_ui(ui: &mut egui::Ui, stats: &ServerStats) {
// Show emission info. // Show emission info.
View::sub_title(ui, format!("{} {}", COINS, t!("network_metrics.emission"))); View::sub_title(ui, format!("{} {}", COINS, t!("network_metrics.emission")));
ui.columns(3, |columns| { ui.columns(3, |columns| {
let supply = stats.header_stats.height as f64 * BLOCK_REWARD; let supply = stats.header_stats.height * BLOCK_REWARD;
let rate = (YEARLY_SUPPLY * 100.0) / supply; let rate = (YEARLY_SUPPLY * 100) / supply;
columns[0].vertical_centered(|ui| { columns[0].vertical_centered(|ui| {
View::rounded_box(ui, View::label_box(
format!("{}", BLOCK_REWARD), ui,
t!("network_metrics.reward"), format!("{}", BLOCK_REWARD),
[true, false, true, false]); t!("network_metrics.reward"),
}); [true, false, true, false],
columns[1].vertical_centered(|ui| { );
View::rounded_box(ui, });
format!("{:.2}%", rate), columns[1].vertical_centered(|ui| {
t!("network_metrics.inflation"), View::label_box(
[false, false, false, false]); ui,
}); format!("{:.2}%", rate),
columns[2].vertical_centered(|ui| { t!("network_metrics.inflation"),
View::rounded_box(ui, [false, false, false, false],
supply.to_string(), );
t!("network_metrics.supply"), });
[false, true, false, true]); columns[2].vertical_centered(|ui| {
}); View::label_box(
}); ui,
ui.add_space(5.0); supply.to_string(),
t!("network_metrics.supply"),
[false, true, false, true],
);
});
});
ui.add_space(5.0);
// Show difficulty adjustment window info. // Show difficulty adjustment window info.
let difficulty_title = t!( let difficulty_title = t!(
"network_metrics.difficulty_window", "network_metrics.difficulty_window",
"size" => stats.diff_stats.window_size "size" => stats.diff_stats.window_size
); );
View::sub_title(ui, format!("{} {}", HOURGLASS_MEDIUM, difficulty_title)); View::sub_title(ui, format!("{} {}", HOURGLASS_MEDIUM, difficulty_title));
ui.columns(3, |columns| { ui.columns(3, |columns| {
columns[0].vertical_centered(|ui| { columns[0].vertical_centered(|ui| {
View::rounded_box(ui, View::label_box(
stats.diff_stats.height.to_string(), ui,
t!("network_node.height"), stats.diff_stats.height.to_string(),
[true, false, true, false]); t!("network_node.height"),
}); [true, false, true, false],
columns[1].vertical_centered(|ui| { );
View::rounded_box(ui, });
format!("{}s", stats.diff_stats.average_block_time), columns[1].vertical_centered(|ui| {
t!("network_metrics.block_time"), View::label_box(
[false, false, false, false]); ui,
}); format!("{}s", stats.diff_stats.average_block_time),
columns[2].vertical_centered(|ui| { t!("network_metrics.block_time"),
View::rounded_box(ui, [false, false, false, false],
stats.diff_stats.average_difficulty.to_string(), );
t!("network_node.difficulty"), });
[false, true, false, true]); columns[2].vertical_centered(|ui| {
}); View::label_box(
}); ui,
stats.diff_stats.average_difficulty.to_string(),
t!("network_node.difficulty"),
[false, true, false, true],
);
});
});
} }
const BLOCK_ITEM_HEIGHT: f32 = 77.0;
/// Draw difficulty adjustment window blocks content. /// Draw difficulty adjustment window blocks content.
fn blocks_ui(ui: &mut egui::Ui, stats: &ServerStats) { fn blocks_ui(ui: &mut egui::Ui, stats: &ServerStats) {
let blocks_size = stats.diff_stats.last_blocks.len(); let blocks_size = stats.diff_stats.last_blocks.len();
ui.add_space(4.0); ui.add_space(4.0);
ScrollArea::vertical() ScrollArea::vertical()
.id_source("difficulty_scroll") .id_salt("mining_difficulty_scroll")
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.stick_to_bottom(true) .stick_to_bottom(true)
.show_rows( .show_rows(ui, BLOCK_ITEM_HEIGHT, blocks_size, |ui, row_range| {
ui, ui.add_space(4.0);
BLOCK_ITEM_HEIGHT, for index in row_range {
blocks_size, let db = stats.diff_stats.last_blocks.get(index).unwrap();
|ui, row_range| { block_item_ui(ui, db, View::item_rounding(index, blocks_size, false));
for index in row_range { }
// Add space before the first item. });
if index == 0 {
ui.add_space(4.0);
}
let db = stats.diff_stats.last_blocks.get(index).unwrap();
block_item_ui(ui, db, View::item_rounding(index, blocks_size, false));
}
},
);
} }
/// Draw block difficulty item. /// Draw block difficulty item.
fn block_item_ui(ui: &mut egui::Ui, db: &DiffBlock, rounding: Rounding) { fn block_item_ui(ui: &mut egui::Ui, db: &DiffBlock, rounding: CornerRadius) {
let mut rect = ui.available_rect_before_wrap(); let mut rect = ui.available_rect_before_wrap();
rect.set_height(BLOCK_ITEM_HEIGHT); rect.set_height(BLOCK_ITEM_HEIGHT);
ui.allocate_ui_at_rect(rect, |ui| { ui.allocate_ui(rect.size(), |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(6.0); ui.add_space(6.0);
ui.vertical(|ui| { ui.vertical(|ui| {
ui.add_space(3.0); ui.add_space(4.0);
// Draw round background. // Draw round background.
rect.min += vec2(8.0, 0.0); rect.min += vec2(8.0, 0.0);
rect.max -= vec2(8.0, 0.0); rect.max -= vec2(8.0, 0.0);
ui.painter().rect(rect, rounding, Colors::white_or_black(false), View::item_stroke()); ui.painter().rect(
rect,
rounding,
Colors::fill(),
View::item_stroke(),
StrokeKind::Outside,
);
// Draw block hash. // Draw block hash.
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(7.0); ui.add_space(8.0);
ui.label(RichText::new(db.block_hash.to_string()) ui.label(
.color(Colors::white_or_black(true)) RichText::new(db.block_hash.to_string())
.size(17.0)); .color(Colors::white_or_black(true))
}); .size(17.0),
// Draw block difficulty and height. );
ui.horizontal(|ui| { });
ui.add_space(6.0); // Draw block difficulty and height.
let diff_text = format!("{} {} {} {}", ui.horizontal(|ui| {
CUBE_TRANSPARENT, ui.add_space(7.0);
db.difficulty, let diff_text = format!(
AT, "{} {} {} {}",
db.block_height); CUBE_TRANSPARENT, db.difficulty, AT, db.block_height
ui.label(RichText::new(diff_text).color(Colors::title(false)).size(16.0)); );
}); ui.label(
// Draw block date. RichText::new(diff_text)
ui.horizontal(|ui| { .color(Colors::title(false))
ui.add_space(6.0); .size(15.0),
let block_time = View::format_time(db.time as i64); );
ui.label(RichText::new(format!("{} {}s {} {}", });
TIMER, // Draw block date.
db.duration, ui.horizontal(|ui| {
HOURGLASS_LOW, ui.add_space(7.0);
block_time)) let block_time = View::format_time(db.time as i64);
.color(Colors::gray()) ui.label(
.size(16.0)); RichText::new(format!(
}); "{} {}s {} {}",
ui.add_space(3.0); TIMER, db.duration, HOURGLASS_LOW, block_time
}); ))
ui.add_space(6.0); .color(Colors::gray())
}); .size(15.0),
}); );
} });
ui.add_space(3.0);
});
ui.add_space(6.0);
});
});
}
+250 -248
View File
@@ -12,290 +12,292 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::{RichText, Rounding, ScrollArea};
use egui::scroll_area::ScrollBarVisibility; use egui::scroll_area::ScrollBarVisibility;
use egui::{CornerRadius, RichText, ScrollArea, StrokeKind};
use grin_chain::SyncStatus; use grin_chain::SyncStatus;
use grin_servers::WorkerStats; use grin_servers::WorkerStats;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::{BARBELL, CLOCK_AFTERNOON, CPU, CUBE, FADERS, FOLDER_DASHED, FOLDER_SIMPLE_MINUS, FOLDER_SIMPLE_PLUS, HARD_DRIVES, PLUGS, PLUGS_CONNECTED, POLYGON}; use crate::gui::icons::{
BARBELL, CLOCK_AFTERNOON, CPU, CUBE, FADERS, FOLDER_DASHED, FOLDER_SIMPLE_MINUS,
FOLDER_SIMPLE_PLUS, HARD_DRIVES, PLUGS, PLUGS_CONNECTED, POLYGON,
};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Content, View};
use crate::gui::views::network::NetworkContent; use crate::gui::views::network::NetworkContent;
use crate::gui::views::network::setup::StratumSetup; use crate::gui::views::network::setup::StratumSetup;
use crate::gui::views::network::types::{NetworkTab, NetworkTabType}; use crate::gui::views::network::types::{NodeTab, NodeTabType};
use crate::gui::views::types::ContentContainer;
use crate::gui::views::{Content, View};
use crate::node::{Node, NodeConfig}; use crate::node::{Node, NodeConfig};
use crate::wallet::WalletConfig;
/// Mining tab content. /// Mining tab content.
pub struct NetworkMining { pub struct NetworkMining {
/// Stratum server setup content. /// Stratum server setup content.
stratum_server_setup: StratumSetup, stratum_server_setup: StratumSetup,
/// Wallet name for rewards.
wallet_name: String,
} }
impl Default for NetworkMining { impl Default for NetworkMining {
fn default() -> Self { fn default() -> Self {
let wallet_name = if let Some(id) = NodeConfig::get_stratum_wallet_id() { Self {
WalletConfig::name_by_id(id).unwrap_or("-".to_string()) stratum_server_setup: StratumSetup::default(),
} else { }
"-".to_string() }
};
Self {
stratum_server_setup: StratumSetup::default(),
wallet_name,
}
}
} }
impl NetworkTab for NetworkMining { impl NodeTab for NetworkMining {
fn get_type(&self) -> NetworkTabType { fn get_type(&self) -> NodeTabType {
NetworkTabType::Mining NodeTabType::Mining
} }
fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { fn tab_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
// Show an error content when available. if Node::is_stratum_starting() || Node::get_sync_status().unwrap() != SyncStatus::NoSync {
let node_err = Node::get_error(); NetworkContent::loading_ui(ui, Some(t!("network_mining.loading")));
if node_err.is_some() { return;
NetworkContent::node_error_ui(ui, node_err.unwrap()); }
return;
}
// Show message to enable node when it's not running. // Show stratum server setup when mining server is not running.
if !Node::is_running() { let stratum_stats = Node::get_stratum_stats();
NetworkContent::disabled_node_ui(ui); if !stratum_stats.is_running {
return; ScrollArea::vertical()
} .id_salt("stratum_setup_scroll")
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(1.0);
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
self.stratum_server_setup.ui(ui, cb);
});
});
return;
}
// Show loading spinner when node is stopping or stratum server is starting. ui.add_space(1.0);
if Node::is_stopping() || Node::is_stratum_starting() {
NetworkContent::loading_ui(ui, None);
return;
}
// Show message when mining is not available. // Show stratum mining server info.
let server_stats = Node::get_stats(); View::sub_title(
if server_stats.is_none() || Node::is_restarting() ui,
|| Node::get_sync_status().unwrap() != SyncStatus::NoSync { format!("{} {}", HARD_DRIVES, t!("network_mining.server")),
NetworkContent::loading_ui(ui, Some(t!("network_mining.loading"))); );
return; ui.columns(2, |columns| {
} columns[0].vertical_centered(|ui| {
let (stratum_addr, stratum_port) = NodeConfig::get_stratum_address();
View::label_box(
ui,
format!("{}:{}", stratum_addr, stratum_port),
t!("network_mining.address"),
[true, false, true, false],
);
});
columns[1].vertical_centered(|ui| {
View::label_box(
ui,
self.stratum_server_setup
.wallet_name
.clone()
.unwrap_or("-".to_string()),
t!("network_mining.rewards_wallet"),
[false, true, false, true],
);
});
});
ui.add_space(4.0);
// Show stratum server setup when mining server is not running. // Show network info.
let stratum_stats = Node::get_stratum_stats(); View::sub_title(ui, format!("{} {}", POLYGON, t!("network.self")));
if !stratum_stats.is_running { ui.columns(3, |columns| {
ScrollArea::vertical() columns[0].vertical_centered(|ui| {
.id_source("stratum_setup_scroll") let difficulty = if stratum_stats.network_difficulty > 0 {
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) stratum_stats.network_difficulty.to_string()
.auto_shrink([false; 2]) } else {
.show(ui, |ui| { "-".into()
ui.add_space(1.0); };
ui.vertical_centered(|ui| { View::label_box(
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| { ui,
self.stratum_server_setup.ui(ui, cb); difficulty,
}); t!("network_node.difficulty"),
}); [true, false, true, false],
}); );
return; });
} columns[1].vertical_centered(|ui| {
let block_height = if stratum_stats.block_height > 0 {
stratum_stats.block_height.to_string()
} else {
"-".into()
};
View::label_box(
ui,
block_height,
t!("network_node.header"),
[false, false, false, false],
);
});
columns[2].vertical_centered(|ui| {
let hashrate = if stratum_stats.network_hashrate > 0.0 {
format!("{:.*}", 2, stratum_stats.network_hashrate)
} else {
"-".into()
};
View::label_box(
ui,
hashrate,
t!("network_mining.hashrate", "bits" => stratum_stats.edge_bits),
[false, true, false, true],
);
});
});
ui.add_space(4.0);
ui.add_space(1.0); // Show mining info.
View::sub_title(ui, format!("{} {}", CPU, t!("network_mining.miners")));
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
View::label_box(
ui,
stratum_stats.num_workers.to_string(),
t!("network_mining.devices"),
[true, false, true, false],
);
});
// Show stratum mining server info. columns[1].vertical_centered(|ui| {
View::sub_title(ui, format!("{} {}", HARD_DRIVES, t!("network_mining.server"))); View::label_box(
ui.columns(2, |columns| { ui,
columns[0].vertical_centered(|ui| { stratum_stats.blocks_found.to_string(),
let (stratum_addr, stratum_port) = NodeConfig::get_stratum_address(); t!("network_mining.blocks_found"),
View::rounded_box(ui, [false, true, false, true],
format!("{}:{}", stratum_addr, stratum_port), );
t!("network_mining.address"), });
[true, false, true, false]); });
}); ui.add_space(4.0);
columns[1].vertical_centered(|ui| {
View::rounded_box(ui,
self.wallet_name.clone(),
t!("network_mining.rewards_wallet"),
[false, true, false, true]);
});
});
ui.add_space(4.0);
// Show network info. // Show workers stats or info text when possible.
View::sub_title(ui, format!("{} {}", POLYGON, t!("network.self"))); let workers_size = stratum_stats.worker_stats.len();
ui.columns(3, |columns| { if workers_size != 0 && stratum_stats.num_workers > 0 {
columns[0].vertical_centered(|ui| { ui.add_space(4.0);
let difficulty = if stratum_stats.network_difficulty > 0 { View::horizontal_line(ui, Colors::item_stroke());
stratum_stats.network_difficulty.to_string() ui.add_space(4.0);
} else { ScrollArea::vertical()
"-".into() .id_salt("stratum_workers_scroll")
}; .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
View::rounded_box(ui, .auto_shrink([false; 2])
difficulty, .show_rows(ui, WORKER_ITEM_HEIGHT, workers_size, |ui, row_range| {
t!("network_node.difficulty"), for index in row_range {
[true, false, true, false]); // Add space before the first item.
}); if index == 0 {
columns[1].vertical_centered(|ui| { ui.add_space(4.0);
let block_height = if stratum_stats.block_height > 0 { }
stratum_stats.block_height.to_string() let worker = stratum_stats.worker_stats.get(index).unwrap();
} else { let item_rounding = View::item_rounding(index, workers_size, false);
"-".into() worker_item_ui(ui, worker, item_rounding);
}; }
View::rounded_box(ui, });
block_height, } else if ui.available_height() > 142.0 {
t!("network_node.header"), View::center_content(ui, 142.0, |ui| {
[false, false, false, false]); ui.label(
}); RichText::new(t!("network_mining.info", "settings" => FADERS))
columns[2].vertical_centered(|ui| { .size(16.0)
let hashrate = if stratum_stats.network_hashrate > 0.0 { .color(Colors::inactive_text()),
format!("{:.*}", 2, stratum_stats.network_hashrate) );
} else { });
"-".into() }
}; }
View::rounded_box(ui,
hashrate,
t!("network_mining.hashrate", "bits" => stratum_stats.edge_bits),
[false, true, false, true]);
});
});
ui.add_space(4.0);
// Show mining info.
View::sub_title(ui, format!("{} {}", CPU, t!("network_mining.miners")));
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
View::rounded_box(ui,
stratum_stats.num_workers.to_string(),
t!("network_mining.devices"),
[true, false, true, false]);
});
columns[1].vertical_centered(|ui| {
View::rounded_box(ui,
stratum_stats.blocks_found.to_string(),
t!("network_mining.blocks_found"),
[false, true, false, true]);
});
});
ui.add_space(4.0);
// Show workers stats or info text when possible.
let workers_size = stratum_stats.worker_stats.len();
if workers_size != 0 && stratum_stats.num_workers > 0 {
ui.add_space(4.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(4.0);
ScrollArea::vertical()
.id_source("stratum_workers_scroll")
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show_rows(
ui,
WORKER_ITEM_HEIGHT,
workers_size,
|ui, row_range| {
for index in row_range {
// Add space before the first item.
if index == 0 {
ui.add_space(4.0);
}
let worker = stratum_stats.worker_stats.get(index).unwrap();
let item_rounding = View::item_rounding(index, workers_size, false);
worker_item_ui(ui, worker, item_rounding);
}
},
);
} else if ui.available_height() > 142.0 {
View::center_content(ui, 142.0, |ui| {
ui.label(RichText::new(t!("network_mining.info", "settings" => FADERS))
.size(16.0)
.color(Colors::inactive_text())
);
});
}
}
} }
/// Height of Stratum server worker list item. /// Height of Stratum server worker list item.
const WORKER_ITEM_HEIGHT: f32 = 76.0; const WORKER_ITEM_HEIGHT: f32 = 76.0;
/// Draw worker statistics item. /// Draw worker statistics item.
fn worker_item_ui(ui: &mut egui::Ui, ws: &WorkerStats, rounding: Rounding) { fn worker_item_ui(ui: &mut egui::Ui, ws: &WorkerStats, rounding: CornerRadius) {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.vertical_centered_justified(|ui| { ui.vertical_centered_justified(|ui| {
// Draw round background. // Draw round background.
let mut rect = ui.available_rect_before_wrap(); let mut rect = ui.available_rect_before_wrap();
rect.set_height(WORKER_ITEM_HEIGHT); rect.set_height(WORKER_ITEM_HEIGHT);
ui.painter().rect(rect, rounding, Colors::white_or_black(false), View::item_stroke()); ui.painter().rect(
rect,
rounding,
Colors::white_or_black(false),
View::item_stroke(),
StrokeKind::Outside,
);
ui.add_space(2.0); ui.add_space(2.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(5.0); ui.add_space(5.0);
// Draw worker connection status. // Draw worker connection status.
let (status_text, status_icon, status_color) = match ws.is_connected { let (status_text, status_icon, status_color) = match ws.is_connected {
true => ( true => (
t!("network_mining.connected"), t!("network_mining.connected"),
PLUGS_CONNECTED, PLUGS_CONNECTED,
Colors::white_or_black(true) Colors::white_or_black(true),
), ),
false => (t!("network_mining.disconnected"), PLUGS, Colors::inactive_text()) false => (
}; t!("network_mining.disconnected"),
let status_line_text = format!("{} {} {}", status_icon, ws.id, status_text); PLUGS,
ui.heading(RichText::new(status_line_text) Colors::inactive_text(),
.color(status_color) ),
.size(17.0)); };
ui.add_space(2.0); let status_line_text = format!("{} {} {}", status_icon, ws.id, status_text);
}); ui.heading(
ui.horizontal(|ui| { RichText::new(status_line_text)
ui.add_space(6.0); .color(status_color)
.size(17.0),
);
ui.add_space(2.0);
});
ui.horizontal(|ui| {
ui.add_space(6.0);
// Draw difficulty. // Draw difficulty.
let diff_text = format!("{} {}", BARBELL, ws.pow_difficulty); let diff_text = format!("{} {}", BARBELL, ws.pow_difficulty);
ui.heading(RichText::new(diff_text) ui.heading(
.color(Colors::title(false)) RichText::new(diff_text)
.size(16.0)); .color(Colors::title(false))
ui.add_space(6.0); .size(16.0),
);
ui.add_space(6.0);
// Draw accepted shares. // Draw accepted shares.
let accepted_text = format!("{} {}", FOLDER_SIMPLE_PLUS, ws.num_accepted); let accepted_text = format!("{} {}", FOLDER_SIMPLE_PLUS, ws.num_accepted);
ui.heading(RichText::new(accepted_text) ui.heading(
.color(Colors::green()) RichText::new(accepted_text)
.size(16.0)); .color(Colors::green())
ui.add_space(6.0); .size(16.0),
);
ui.add_space(6.0);
// Draw rejected shares. // Draw rejected shares.
let rejected_text = format!("{} {}", FOLDER_SIMPLE_MINUS, ws.num_rejected); let rejected_text = format!("{} {}", FOLDER_SIMPLE_MINUS, ws.num_rejected);
ui.heading(RichText::new(rejected_text) ui.heading(RichText::new(rejected_text).color(Colors::red()).size(16.0));
.color(Colors::red()) ui.add_space(6.0);
.size(16.0));
ui.add_space(6.0);
// Draw stale shares. // Draw stale shares.
let stale_text = format!("{} {}", FOLDER_DASHED, ws.num_stale); let stale_text = format!("{} {}", FOLDER_DASHED, ws.num_stale);
ui.heading(RichText::new(stale_text) ui.heading(RichText::new(stale_text).color(Colors::gray()).size(16.0));
.color(Colors::gray()) ui.add_space(6.0);
.size(16.0));
ui.add_space(6.0);
// Draw blocks found. // Draw blocks found.
let blocks_found_text = format!("{} {}", CUBE, ws.num_blocks_found); let blocks_found_text = format!("{} {}", CUBE, ws.num_blocks_found);
ui.heading(RichText::new(blocks_found_text) ui.heading(
.color(Colors::title(false)) RichText::new(blocks_found_text)
.size(16.0)); .color(Colors::title(false))
}); .size(16.0),
ui.horizontal(|ui| { );
ui.add_space(6.0); });
ui.horizontal(|ui| {
ui.add_space(6.0);
// Draw block time // Draw block time
let seen_ts = ws.last_seen.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); let seen_ts = ws
let seen_time = View::format_time(seen_ts as i64); .last_seen
let seen_text = format!("{} {}", CLOCK_AFTERNOON, seen_time); .duration_since(std::time::UNIX_EPOCH)
ui.heading(RichText::new(seen_text) .unwrap()
.color(Colors::gray()) .as_secs();
.size(16.0)); let seen_time = View::format_time(seen_ts as i64);
}); let seen_text = format!("{} {}", CLOCK_AFTERNOON, seen_time);
}); ui.heading(RichText::new(seen_text).color(Colors::gray()).size(16.0));
}); });
} });
});
}
+1 -1
View File
@@ -33,5 +33,5 @@ pub use content::*;
mod connections; mod connections;
pub use connections::*; pub use connections::*;
pub mod modals;
pub mod types; pub mod types;
pub mod modals;
+238 -131
View File
@@ -14,153 +14,260 @@
use egui::{Id, RichText}; use egui::{Id, RichText};
use url::Url; use url::Url;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::SCAN;
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, View}; use crate::gui::views::network::types::ShareConnection;
use crate::gui::views::types::TextEditOptions; use crate::gui::views::{CameraContent, Modal, TextEdit, View};
use crate::wallet::{ConnectionsConfig, ExternalConnection}; use crate::wallet::{ConnectionsConfig, ExternalConnection};
/// Content to create or update external wallet connection. /// Content to create or update external wallet connection.
pub struct ExternalConnectionModal { pub struct ExternalConnectionModal {
/// Flag to check if [`Modal`] was just opened to focus on input field. /// Flag to check if content was just rendered.
first_modal_launch: bool, first_draw: bool,
/// External connection URL value for [`Modal`].
ext_node_url_edit: String, /// Editing external connection identifier.
/// External connection API secret value for [`Modal`]. id: Option<i64>,
ext_node_secret_edit: String,
/// Flag to show URL format error at [`Modal`]. /// External connection URL.
ext_node_url_error: bool, url_edit: String,
/// Editing external connection identifier for [`Modal`]. /// Flag to show URL format error.
ext_conn_id: Option<i64>, url_error: bool,
// /// External connection username.
// username_edit: String,
/// External connection API secret.
secret_edit: String,
/// QR code scanner content.
scan_qr_content: Option<CameraContent>,
} }
impl ExternalConnectionModal { impl ExternalConnectionModal {
/// Network [`Modal`] identifier. /// Network [`Modal`] identifier.
pub const NETWORK_ID: &'static str = "net_ext_conn_modal"; pub const NETWORK_ID: &'static str = "net_ext_conn_modal";
/// Wallet [`Modal`] identifier. /// Wallet [`Modal`] identifier.
pub const WALLET_ID: &'static str = "wallet_ext_conn_modal"; pub const WALLET_ID: &'static str = "wallet_ext_conn_modal";
/// Create new instance from optional provided connection to update. /// Create new instance from optional provided connection to update.
pub fn new(conn: Option<ExternalConnection>) -> Self { pub fn new(conn: Option<ExternalConnection>) -> Self {
let (ext_node_url_edit, ext_node_secret_edit, ext_conn_id) = if let Some(c) = conn { let (url_edit, secret_edit, id) = if let Some(c) = conn {
(c.url, c.secret.unwrap_or("".to_string()), Some(c.id)) // let username = c.username.unwrap_or("grin".to_string());
} else { let secret = c.secret.unwrap_or("".to_string());
("".to_string(), "".to_string(), None) (c.url, secret, Some(c.id))
}; } else {
Self { ("".to_string(), "".to_string(), None)
first_modal_launch: true, };
ext_node_url_edit, Self {
ext_node_secret_edit, first_draw: true,
ext_node_url_error: false, url_edit,
ext_conn_id, url_error: false,
} secret_edit,
} id,
scan_qr_content: None,
}
}
/// Draw external connection [`Modal`] content. /// Draw external connection [`Modal`] content.
pub fn ui(&mut self, pub fn ui(
ui: &mut egui::Ui, &mut self,
cb: &dyn PlatformCallbacks, ui: &mut egui::Ui,
modal: &Modal, cb: &dyn PlatformCallbacks,
on_save: impl Fn(ExternalConnection)) { modal: &Modal,
ui.add_space(6.0); on_save: impl Fn(ExternalConnection),
ui.vertical_centered(|ui| { ) {
ui.label(RichText::new(t!("wallets.node_url")) // Show QR code scanner content.
.size(17.0) if let Some(scan_content) = self.scan_qr_content.as_mut() {
.color(Colors::gray())); if let Some(result) = scan_content.qr_scan_result() {
ui.add_space(8.0); cb.stop_camera();
modal.enable_closing();
self.scan_qr_content = None;
// Parse scan result.
if let Ok(c) = serde_json::from_str::<ShareConnection>(&result.text()) {
let ext_conn = ExternalConnection::new(c.url, Some(c.username), Some(c.secret));
ConnectionsConfig::add_ext_conn(ext_conn.clone());
ExternalConnection::check(Some(ext_conn.id), ui.ctx());
Modal::close();
}
} else {
scan_content.ui(ui, cb);
}
ui.add_space(8.0);
// Draw node URL text edit. // Setup spacing between buttons.
let url_edit_id = Id::from(modal.id).with(self.ext_conn_id); ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
let mut url_edit_opts = TextEditOptions::new(url_edit_id).paste().no_focus();
if self.first_modal_launch {
self.first_modal_launch = false;
url_edit_opts.focus = true;
}
View::text_edit(ui, cb, &mut self.ext_node_url_edit, &mut url_edit_opts);
ui.add_space(8.0);
ui.label(RichText::new(t!("wallets.node_secret")) // Show buttons to close modal or scanner.
.size(17.0) ui.columns(2, |cols| {
.color(Colors::gray())); cols[0].vertical_centered_justified(|ui| {
ui.add_space(8.0); View::button(ui, t!("close"), Colors::white_or_black(false), || {
cb.stop_camera();
self.scan_qr_content = None;
Modal::close();
});
});
cols[1].vertical_centered_justified(|ui| {
View::button(ui, t!("back"), Colors::white_or_black(false), || {
cb.stop_camera();
self.scan_qr_content = None;
modal.enable_closing();
});
});
});
ui.add_space(6.0);
return;
}
// Add connection button callback.
let on_add = |ui: &mut egui::Ui, m: &mut ExternalConnectionModal| {
let url = if !m.url_edit.starts_with("http") {
format!("https://{}", m.url_edit)
} else {
m.url_edit.clone()
};
let error = Url::parse(url.trim()).is_err();
m.url_error = error;
if !error {
let username = if m.secret_edit.is_empty() {
Some("grin".to_string())
} else {
Some(m.secret_edit.clone())
};
let secret = if m.secret_edit.is_empty() {
None
} else {
Some(m.secret_edit.clone())
};
// Update or create new connection.
let mut ext_conn = ExternalConnection::new(url, username, secret);
if let Some(id) = m.id {
ext_conn.id = id;
}
ConnectionsConfig::add_ext_conn(ext_conn.clone());
ExternalConnection::check(Some(ext_conn.id), ui.ctx());
on_save(ext_conn);
// Draw node API secret text edit. // Close modal.
let secret_edit_id = Id::from(modal.id).with(self.ext_conn_id).with("node_secret"); m.url_edit = "".to_string();
let mut secret_edit_opts = TextEditOptions::new(secret_edit_id).paste().no_focus(); m.secret_edit = "".to_string();
View::text_edit(ui, cb, &mut self.ext_node_secret_edit, &mut secret_edit_opts); m.url_error = false;
Modal::close();
}
};
// Show error when specified URL is not valid. ui.vertical_centered(|ui| {
if self.ext_node_url_error { ui.add_space(6.0);
ui.add_space(12.0); ui.label(
ui.label(RichText::new(t!("wallets.invalid_url")) RichText::new(t!("wallets.node_url"))
.size(17.0) .size(17.0)
.color(Colors::red())); .color(Colors::gray()),
} );
ui.add_space(12.0); ui.add_space(8.0);
});
// Show modal buttons. // Draw node URL text edit.
ui.scope(|ui| { let url_edit_id = Id::from(modal.id).with(self.id).with("node_url");
// Setup spacing between buttons. let mut url_edit = TextEdit::new(url_edit_id).paste().focus(self.first_draw);
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); let url_edit_before = self.url_edit.clone();
url_edit.ui(ui, &mut self.url_edit, cb);
if self.url_edit != url_edit_before {
self.url_error = false;
}
ui.columns(2, |columns| { // ui.add_space(8.0);
columns[0].vertical_centered_justified(|ui| { // ui.label(RichText::new(t!("wallets.name"))
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { // .size(17.0)
// Close modal. // .color(Colors::gray()));
self.ext_node_url_edit = "".to_string(); // ui.add_space(8.0);
self.ext_node_secret_edit = "".to_string(); //
self.ext_node_url_error = false; // // Draw node username text edit (disabled by default).
cb.hide_keyboard(); // let username_edit_id = Id::from(modal.id).with(self.id).with("node_username");
modal.close(); // let mut username_edit = TextEdit::new(username_edit_id).focus(false).disable();
}); // username_edit.ui(ui, &mut self.username_edit, cb);
});
columns[1].vertical_centered_justified(|ui| {
// Add connection button callback.
let mut on_add = || {
if !self.ext_node_url_edit.starts_with("http") {
self.ext_node_url_edit = format!("http://{}", self.ext_node_url_edit)
}
let error = Url::parse(self.ext_node_url_edit.as_str()).is_err();
self.ext_node_url_error = error;
if !error {
let url = self.ext_node_url_edit.to_owned();
let secret = if self.ext_node_secret_edit.is_empty() {
None
} else {
Some(self.ext_node_secret_edit.to_owned())
};
// Update or create new connection. ui.add_space(8.0);
let mut ext_conn = ExternalConnection::new(url, secret); ui.label(
if let Some(id) = self.ext_conn_id { RichText::new(t!("wallets.node_secret"))
ext_conn.id = id; .size(17.0)
} .color(Colors::gray()),
ConnectionsConfig::add_ext_conn(ext_conn.clone()); );
ExternalConnection::check_ext_conn_availability(Some(ext_conn.id)); ui.add_space(8.0);
on_save(ext_conn);
// Close modal. // Draw node API secret text edit.
self.ext_node_url_edit = "".to_string(); let secret_edit_id = Id::from(modal.id).with(self.id).with("node_secret");
self.ext_node_secret_edit = "".to_string(); let mut secret_edit = TextEdit::new(secret_edit_id)
self.ext_node_url_error = false; .h_center()
cb.hide_keyboard(); .password()
modal.close(); .paste()
} .focus(false);
}; if url_edit.enter_pressed {
View::on_enter_key(ui, || { secret_edit.focus_request();
(on_add)(); }
}); secret_edit.ui(ui, &mut self.secret_edit, cb);
View::button(ui, if self.ext_conn_id.is_some() { if secret_edit.enter_pressed {
t!("modal.save") on_add(ui, self);
} else { }
t!("modal.add")
}, Colors::white_or_black(false), on_add); // Show error when specified URL is not valid.
}); if self.url_error {
}); ui.add_space(12.0);
ui.add_space(6.0); ui.label(
}); RichText::new(t!("wallets.invalid_url"))
} .size(17.0)
} .color(Colors::red()),
);
}
ui.add_space(12.0);
let scan_text = format!("{} {}", SCAN, t!("scan"));
View::button(ui, scan_text, Colors::white_or_black(false), || {
modal.disable_closing();
self.scan_qr_content = Some(CameraContent::default());
cb.start_camera();
});
});
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
// Close modal.
self.url_edit = "".to_string();
self.secret_edit = "".to_string();
self.url_error = false;
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button_ui(
ui,
if self.id.is_some() {
t!("modal.save")
} else {
t!("modal.add")
},
Colors::white_or_black(false),
|ui| {
(on_add)(ui, self);
},
);
});
});
ui.add_space(6.0);
});
self.first_draw = false;
}
}
+4 -1
View File
@@ -13,4 +13,7 @@
// limitations under the License. // limitations under the License.
mod ext_conn; mod ext_conn;
pub use ext_conn::*; pub use ext_conn::*;
mod share_conn;
pub use share_conn::*;
@@ -0,0 +1,57 @@
// Copyright 2024 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::AppConfig;
use crate::gui::Colors;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::network::types::ShareConnection;
use crate::gui::views::{Modal, QrCodeContent, View};
/// [`Modal`] content to share connection with QR code.
pub struct ShareConnectionContent {
/// QR code content.
pub qr_details_content: QrCodeContent,
}
impl ShareConnectionContent {
/// Create new content instance from connection details.
pub fn new(details: ShareConnection) -> Result<Self, serde_json::Error> {
let details = serde_json::to_string_pretty(&details)?;
let c = Self {
qr_details_content: QrCodeContent::new(details, false).hide_text().no_copy(),
};
Ok(c)
}
/// Draw QR code content.
pub fn ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
let dark_theme = AppConfig::dark_theme().unwrap_or(false);
// Set light theme for better scanning.
AppConfig::set_dark_theme(false);
modal.set_background_color(Colors::FILL_DEEP);
crate::setup_visuals(ui.ctx());
// Draw QR code content.
ui.add_space(6.0);
self.qr_details_content.ui(ui, cb);
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("close"), Colors::white_or_black(false), || {
Modal::close();
});
});
ui.add_space(6.0);
// Set color theme back.
AppConfig::set_dark_theme(dark_theme);
crate::setup_visuals(ui.ctx());
}
}
+212 -193
View File
@@ -12,224 +12,243 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::{RichText, Rounding, ScrollArea};
use egui::scroll_area::ScrollBarVisibility; use egui::scroll_area::ScrollBarVisibility;
use egui::{CornerRadius, RichText, ScrollArea, StrokeKind};
use grin_servers::PeerStats; use grin_servers::PeerStats;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::{AT, CUBE, DEVICES, FLOW_ARROW, HANDSHAKE, PACKAGE, SHARE_NETWORK}; use crate::gui::icons::{AT, CUBE, DEVICES, FLOW_ARROW, HANDSHAKE, PACKAGE, SHARE_NETWORK};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::network::types::{NodeTab, NodeTabType};
use crate::gui::views::{Content, View}; use crate::gui::views::{Content, View};
use crate::gui::views::network::NetworkContent;
use crate::gui::views::network::types::{NetworkTab, NetworkTabType};
use crate::node::{Node, NodeConfig}; use crate::node::{Node, NodeConfig};
/// Integrated node tab content. /// Integrated node tab content.
#[derive(Default)] #[derive(Default)]
pub struct NetworkNode; pub struct NetworkNode;
impl NetworkTab for NetworkNode { impl NodeTab for NetworkNode {
fn get_type(&self) -> NetworkTabType { fn get_type(&self) -> NodeTabType {
NetworkTabType::Node NodeTabType::Info
} }
fn ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) { fn tab_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) {
// Show an error content when available. ScrollArea::vertical()
let node_err = Node::get_error(); .id_salt("integrated_node_info_scroll")
if node_err.is_some() { .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
NetworkContent::node_error_ui(ui, node_err.unwrap()); .auto_shrink([false; 2])
return; .show(ui, |ui| {
} ui.add_space(2.0);
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
// Show message to enable node when it's not running. // Show node stats content.
if !Node::is_running() { node_stats_ui(ui);
NetworkContent::disabled_node_ui(ui); });
return; });
} }
// Show loading spinner when stats are not available.
let server_stats = Node::get_stats();
if server_stats.is_none() || Node::is_restarting() || Node::is_stopping() {
NetworkContent::loading_ui(ui, None);
return;
}
ScrollArea::vertical()
.id_source("integrated_node")
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(2.0);
ui.vertical_centered(|ui| {
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
// Show node stats content.
node_stats_ui(ui);
});
});
});
}
} }
/// Draw node statistics content. /// Draw node statistics content.
fn node_stats_ui(ui: &mut egui::Ui) { fn node_stats_ui(ui: &mut egui::Ui) {
let server_stats = Node::get_stats(); let server_stats = Node::get_stats();
let stats = server_stats.as_ref().unwrap(); let stats = server_stats.as_ref().unwrap();
// Show header info. // Show header info.
View::sub_title(ui, format!("{} {}", FLOW_ARROW, t!("network_node.header"))); View::sub_title(ui, format!("{} {}", FLOW_ARROW, t!("network_node.header")));
ui.columns(2, |columns| { ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| { columns[0].vertical_centered(|ui| {
View::rounded_box(ui, View::label_box(
stats.header_stats.last_block_h.to_string(), ui,
t!("network_node.hash"), stats.header_stats.last_block_h.to_string(),
[true, false, false, false]); t!("network_node.hash"),
}); [true, false, false, false],
columns[1].vertical_centered(|ui| { );
View::rounded_box(ui, });
stats.header_stats.height.to_string(), columns[1].vertical_centered(|ui| {
t!("network_node.height"), View::label_box(
[false, true, false, false]); ui,
}); stats.header_stats.height.to_string(),
}); t!("network_node.height"),
ui.columns(2, |columns| { [false, true, false, false],
columns[0].vertical_centered(|ui| { );
View::rounded_box(ui, });
stats.header_stats.total_difficulty.to_string(), });
t!("network_node.difficulty"), ui.columns(2, |columns| {
[false, false, true, false]); columns[0].vertical_centered(|ui| {
}); View::label_box(
columns[1].vertical_centered(|ui| { ui,
let h_ts = stats.header_stats.latest_timestamp.timestamp(); stats.header_stats.total_difficulty.to_string(),
let h_time = View::format_time(h_ts); t!("network_node.difficulty"),
View::rounded_box(ui, [false, false, true, false],
h_time, );
t!("network_node.time"), });
[false, false, false, true]); columns[1].vertical_centered(|ui| {
}); let h_ts = stats.header_stats.latest_timestamp.timestamp();
}); let h_time = View::format_time(h_ts);
ui.add_space(5.0); View::label_box(
ui,
h_time,
t!("network_node.time"),
[false, false, false, true],
);
});
});
ui.add_space(5.0);
// Show block info. // Show block info.
View::sub_title(ui, format!("{} {}", CUBE, t!("network_node.block"))); View::sub_title(ui, format!("{} {}", CUBE, t!("network_node.block")));
ui.columns(2, |columns| { ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| { columns[0].vertical_centered(|ui| {
View::rounded_box(ui, View::label_box(
stats.chain_stats.last_block_h.to_string(), ui,
t!("network_node.hash"), stats.chain_stats.last_block_h.to_string(),
[true, false, false, false]); t!("network_node.hash"),
}); [true, false, false, false],
columns[1].vertical_centered(|ui| { );
View::rounded_box(ui, });
stats.chain_stats.height.to_string(), columns[1].vertical_centered(|ui| {
t!("network_node.height"), View::label_box(
[false, true, false, false]); ui,
}); stats.chain_stats.height.to_string(),
}); t!("network_node.height"),
ui.columns(2, |columns| { [false, true, false, false],
columns[0].vertical_centered(|ui| { );
View::rounded_box(ui, });
stats.chain_stats.total_difficulty.to_string(), });
t!("network_node.difficulty"), ui.columns(2, |columns| {
[false, false, true, false]); columns[0].vertical_centered(|ui| {
}); View::label_box(
columns[1].vertical_centered(|ui| { ui,
let b_ts = stats.chain_stats.latest_timestamp.timestamp(); stats.chain_stats.total_difficulty.to_string(),
let b_time = View::format_time(b_ts); t!("network_node.difficulty"),
View::rounded_box(ui, [false, false, true, false],
b_time, );
t!("network_node.time"), });
[false, false, false, true]); columns[1].vertical_centered(|ui| {
}); let b_ts = stats.chain_stats.latest_timestamp.timestamp();
}); let b_time = View::format_time(b_ts);
ui.add_space(5.0); View::label_box(
ui,
b_time,
t!("network_node.time"),
[false, false, false, true],
);
});
});
ui.add_space(5.0);
// Show data info. // Show data info.
View::sub_title(ui, format!("{} {}", SHARE_NETWORK, t!("network_node.data"))); View::sub_title(ui, format!("{} {}", SHARE_NETWORK, t!("network_node.data")));
ui.columns(2, |columns| { ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| { columns[0].vertical_centered(|ui| {
let tx_stat = match &stats.tx_stats { let tx_stat = match &stats.tx_stats {
None => "0 (0)".to_string(), None => "0 (0)".to_string(),
Some(tx) => format!("{} ({})", tx.tx_pool_size, tx.tx_pool_kernels) Some(tx) => format!("{} ({})", tx.tx_pool_size, tx.tx_pool_kernels),
}; };
View::rounded_box(ui, View::label_box(
tx_stat, ui,
t!("network_node.main_pool"), tx_stat,
[true, false, false, false]); t!("network_node.main_pool"),
}); [true, false, false, false],
columns[1].vertical_centered(|ui| { );
let stem_tx_stat = match &stats.tx_stats { });
None => "0 (0)".to_string(), columns[1].vertical_centered(|ui| {
Some(stx) => format!("{} ({})", let stem_tx_stat = match &stats.tx_stats {
stx.stem_pool_size, None => "0 (0)".to_string(),
stx.stem_pool_kernels) Some(stx) => format!("{} ({})", stx.stem_pool_size, stx.stem_pool_kernels),
}; };
View::rounded_box(ui, View::label_box(
stem_tx_stat, ui,
t!("network_node.stem_pool"), stem_tx_stat,
[false, true, false, false]); t!("network_node.stem_pool"),
}); [false, true, false, false],
}); );
ui.columns(2, |columns| { });
columns[0].vertical_centered(|ui| { });
View::rounded_box(ui, ui.columns(2, |columns| {
stats.disk_usage_gb.to_string(), columns[0].vertical_centered(|ui| {
t!("network_node.size"), View::label_box(
[false, false, true, false]); ui,
}); stats.disk_usage_gb.to_string(),
columns[1].vertical_centered(|ui| { t!("network_node.size"),
let peers_txt = format!("{} ({})", [false, false, true, false],
stats.peer_count, );
NodeConfig::get_max_outbound_peers()); });
View::rounded_box(ui, peers_txt, t!("network_node.peers"), [false, false, false, true]); columns[1].vertical_centered(|ui| {
}); let peers_txt = format!(
}); "{} ({})",
ui.add_space(5.0); stats.peer_count,
NodeConfig::get_max_outbound_peers()
);
View::label_box(
ui,
peers_txt,
t!("network_node.peers"),
[false, false, false, true],
);
});
});
ui.add_space(5.0);
// Show peer stats when available. // Show peer stats when available.
if stats.peer_count > 0 { if stats.peer_count > 0 {
View::sub_title(ui, format!("{} {}", HANDSHAKE, t!("network_node.peers"))); View::sub_title(ui, format!("{} {}", HANDSHAKE, t!("network_node.peers")));
let peers = &stats.peer_stats; let peers = &stats.peer_stats;
for (index, ps) in peers.iter().enumerate() { for (index, ps) in peers.iter().enumerate() {
peer_item_ui(ui, ps, View::item_rounding(index, peers.len(), false)); peer_item_ui(ui, ps, View::item_rounding(index, peers.len(), false));
} }
ui.add_space(5.0); ui.add_space(5.0);
} }
} }
const PEER_ITEM_HEIGHT: f32 = 77.0;
/// Draw connected peer info item. /// Draw connected peer info item.
fn peer_item_ui(ui: &mut egui::Ui, peer: &PeerStats, rounding: Rounding) { fn peer_item_ui(ui: &mut egui::Ui, peer: &PeerStats, r: CornerRadius) {
let mut rect = ui.available_rect_before_wrap(); let mut rect = ui.available_rect_before_wrap();
rect.set_height(79.0); rect.set_height(PEER_ITEM_HEIGHT);
ui.allocate_ui_at_rect(rect, |ui| { ui.allocate_ui(rect.size(), |ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.add_space(4.0); ui.add_space(4.0);
// Draw round background. // Draw round background.
ui.painter().rect(rect, rounding, Colors::white_or_black(false), View::item_stroke()); ui.painter().rect(
rect,
r,
Colors::fill(),
View::item_stroke(),
StrokeKind::Outside,
);
// Draw peer address // Draw IP address.
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(7.0); ui.add_space(7.0);
ui.label(RichText::new(&peer.addr).color(Colors::white_or_black(true)).size(17.0)); ui.label(
}); RichText::new(&peer.addr)
// Draw peer difficulty and height .color(Colors::white_or_black(true))
ui.horizontal(|ui| { .size(17.0),
ui.add_space(6.0); );
let diff_text = format!("{} {} {} {}", });
PACKAGE, // Draw difficulty and height.
peer.total_difficulty, ui.horizontal(|ui| {
AT, ui.add_space(6.0);
peer.height); let diff_text = format!(
ui.label(RichText::new(diff_text).color(Colors::title(false)).size(16.0)); "{} {} {} {}",
}); PACKAGE, peer.total_difficulty, AT, peer.height
// Draw peer user-agent );
ui.horizontal(|ui| { ui.label(
ui.add_space(6.0); RichText::new(diff_text)
let agent_text = format!("{} {}", DEVICES, &peer.user_agent); .color(Colors::title(false))
ui.label(RichText::new(agent_text).color(Colors::gray()).size(16.0)); .size(15.0),
}); );
});
// Draw user-agent.
ui.horizontal(|ui| {
ui.add_space(6.0);
let agent_text = format!("{} {}", DEVICES, &peer.user_agent);
ui.label(RichText::new(agent_text).color(Colors::gray()).size(15.0));
});
ui.add_space(3.0); ui.add_space(3.0);
}); });
}); });
} }
+298 -212
View File
@@ -12,252 +12,338 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::{RichText, ScrollArea};
use egui::scroll_area::ScrollBarVisibility;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::ARROW_COUNTER_CLOCKWISE; use crate::gui::icons::{ARROW_COUNTER_CLOCKWISE, TRASH};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, Content, View}; use crate::gui::views::network::setup::{
use crate::gui::views::network::setup::{DandelionSetup, NodeSetup, P2PSetup, PoolSetup, StratumSetup}; DandelionSetup, NodeSetup, P2PSetup, PoolSetup, StratumSetup,
use crate::gui::views::network::types::{NetworkTab, NetworkTabType}; };
use crate::gui::views::types::{ModalContainer, ModalPosition}; use crate::gui::views::network::types::{NodeTab, NodeTabType};
use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::{Content, Modal, View};
use crate::node::{Node, NodeConfig}; use crate::node::{Node, NodeConfig};
use egui::scroll_area::ScrollBarVisibility;
use egui::{RichText, ScrollArea};
/// Integrated node settings tab content. /// Integrated node settings tab content.
pub struct NetworkSettings { pub struct NetworkSettings {
/// Integrated node general setup content. /// Integrated node general setup content.
node: NodeSetup, node: NodeSetup,
/// P2P server setup content. /// P2P server setup content.
p2p: P2PSetup, p2p: P2PSetup,
/// Stratum server setup content. /// Stratum server setup content.
stratum: StratumSetup, stratum: StratumSetup,
/// Pool setup content. /// Pool setup content.
pool: PoolSetup, pool: PoolSetup,
/// Dandelion server setup content. /// Dandelion server setup content.
dandelion: DandelionSetup, dandelion: DandelionSetup,
/// [`Modal`] identifiers allowed at this ui container. /// Flag to check if reset of data was called.
modal_ids: Vec<&'static str> data_reset: bool,
} }
/// Identifier for settings reset confirmation [`Modal`]. /// Identifier for settings reset confirmation [`Modal`].
pub const RESET_SETTINGS_MODAL: &'static str = "reset_settings"; pub const RESET_SETTINGS_CONFIRMATION_MODAL: &'static str = "reset_settings_confirmation";
impl Default for NetworkSettings { impl Default for NetworkSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
node: NodeSetup::default(), node: NodeSetup::default(),
p2p: P2PSetup::default(), p2p: P2PSetup::default(),
stratum: StratumSetup::default(), stratum: StratumSetup::default(),
pool: PoolSetup::default(), pool: PoolSetup::default(),
dandelion: DandelionSetup::default(), dandelion: DandelionSetup::default(),
modal_ids: vec![ data_reset: false,
RESET_SETTINGS_MODAL }
] }
}
}
} }
impl ModalContainer for NetworkSettings { impl ContentContainer for NetworkSettings {
fn modal_ids(&self) -> &Vec<&'static str> { fn modal_ids(&self) -> Vec<&'static str> {
&self.modal_ids vec![RESET_SETTINGS_CONFIRMATION_MODAL]
} }
fn modal_ui(&mut self, fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, _: &dyn PlatformCallbacks) {
ui: &mut egui::Ui, match modal.id {
modal: &Modal, RESET_SETTINGS_CONFIRMATION_MODAL => reset_settings_confirmation_modal(ui),
_: &dyn PlatformCallbacks) { _ => {}
match modal.id { }
RESET_SETTINGS_MODAL => reset_settings_confirmation_modal(ui, modal), }
_ => {}
} fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
} ScrollArea::vertical()
.id_salt("node_settings_scroll")
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(1.0);
ui.vertical_centered(|ui| {
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
// Draw node setup section.
self.node.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(4.0);
// Draw P2P server setup section.
self.p2p.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(4.0);
// Draw Stratum server setup section.
self.stratum.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(4.0);
// Draw pool setup section.
self.pool.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(4.0);
// Draw Dandelion server setup section.
self.dandelion.ui(ui, cb);
// Draw content to reset the data.
if !Node::is_restarting() && !self.data_reset {
ui.add_space(4.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
self.reset_data_ui(ui);
}
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(6.0);
// Draw reset settings content.
reset_settings_ui(ui);
});
});
});
}
} }
impl NetworkTab for NetworkSettings { impl NodeTab for NetworkSettings {
fn get_type(&self) -> NetworkTabType { fn get_type(&self) -> NodeTabType {
NetworkTabType::Settings NodeTabType::Settings
} }
fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { fn tab_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
// Draw modal content for current ui container. self.ui(ui, cb);
self.current_modal_ui(ui, cb); }
ScrollArea::vertical()
.id_source("network_settings")
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(1.0);
ui.vertical_centered(|ui| {
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
// Draw node setup section.
self.node.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(4.0);
// Draw P2P server setup section.
self.p2p.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(4.0);
// Draw Stratum server setup section.
self.stratum.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(4.0);
// Draw pool setup section.
self.pool.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(4.0);
// Draw Dandelion server setup section.
self.dandelion.ui(ui, cb);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(6.0);
// Draw reset settings content.
reset_settings_ui(ui);
});
});
});
}
} }
impl NetworkSettings { impl NetworkSettings {
/// Reminder to restart enabled node to show on edit setting at [`Modal`]. /// Reminder to restart enabled node to show on edit setting at [`Modal`].
pub fn node_restart_required_ui(ui: &mut egui::Ui) { pub fn node_restart_required_ui(ui: &mut egui::Ui) {
if Node::is_running() { if Node::is_running() {
ui.add_space(12.0); ui.add_space(12.0);
ui.label(RichText::new(t!("network_settings.restart_node_required")) ui.label(
.size(16.0) RichText::new(t!("network_settings.restart_node_required"))
.color(Colors::green()) .size(16.0)
); .color(Colors::green()),
} );
} }
}
/// Draw IP addresses as radio buttons. /// Draw IP addresses as radio buttons.
pub fn ip_addrs_ui(ui: &mut egui::Ui, pub fn ip_addrs_ui(
saved_ip: &String, ui: &mut egui::Ui,
ips: &Vec<String>, saved_ip: &String,
on_change: impl FnOnce(&String)) { ips: &Vec<String>,
let mut selected_ip = saved_ip; on_change: impl FnOnce(&String),
) {
let mut all = NodeConfig::ALL_INTERFACES.to_string();
let all_ips = saved_ip == &all || saved_ip == &format!("[{}]", &all);
if all_ips {
all = saved_ip.clone();
}
// Set first IP address as current if saved is not present at system. let mut selected_ip = saved_ip.clone();
if !ips.contains(saved_ip) {
selected_ip = ips.get(0).unwrap();
}
ui.add_space(2.0); let mut listen_all_changed = false;
View::checkbox(ui, all_ips, t!("network_settings.ip_listen_all"), || {
listen_all_changed = true;
});
if listen_all_changed {
let new_ip = if all_ips {
ips.get(0).unwrap_or(&all).clone()
} else {
all.clone()
};
selected_ip = new_ip;
}
// Show available IP addresses on the system. ui.add_space(8.0);
let _ = ips.chunks(2).map(|x| {
if x.len() == 2 {
ui.columns(2, |columns| {
let ip_left = x.get(0).unwrap();
columns[0].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_ip, ip_left, ip_left.to_string());
});
let ip_right = x.get(1).unwrap();
columns[1].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_ip, ip_right, ip_right.to_string());
})
});
} else {
let ip = x.get(0).unwrap();
View::radio_value(ui, &mut selected_ip, ip, ip.to_string());
}
ui.add_space(12.0);
}).collect::<Vec<_>>();
if saved_ip != selected_ip { if selected_ip != all {
(on_change)(&selected_ip.to_string()); // Set first IP address as current if saved is not present at system.
} if !ips.contains(&saved_ip) {
} selected_ip = ips.get(0).unwrap().clone();
}
/// Show message when IP addresses are not available at system. // Show available IP addresses on the system.
pub fn no_ip_address_ui(ui: &mut egui::Ui) { let _ = ips
ui.vertical_centered(|ui| { .chunks(2)
ui.label(RichText::new(t!("network.no_ips")) .map(|x| {
.size(16.0) if x.len() == 2 {
.color(Colors::inactive_text()) ui.columns(2, |columns| {
); let ip_left = x.get(0).unwrap();
ui.add_space(6.0); let val = if all_ips {
}); &mut ip_left.clone()
} } else {
&mut selected_ip
};
columns[0].vertical_centered(|ui| {
View::radio_value(ui, val, ip_left.clone(), ip_left.to_string());
});
let ip_right = x.get(1).unwrap();
let val = if all_ips {
&mut ip_right.clone()
} else {
&mut selected_ip
};
columns[1].vertical_centered(|ui| {
View::radio_value(ui, val, ip_right.clone(), ip_right.to_string());
})
});
} else {
let ip = x.get(0).unwrap();
let val = if all_ips {
&mut ip.clone()
} else {
&mut selected_ip
};
View::radio_value(ui, val, ip.clone(), ip.to_string());
}
ui.add_space(12.0);
})
.collect::<Vec<_>>();
}
if saved_ip != &selected_ip {
on_change(&selected_ip.to_string());
}
}
/// Show message when IP addresses are not available at system.
pub fn no_ip_address_ui(ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("network.no_ips"))
.size(16.0)
.color(Colors::inactive_text()),
);
ui.add_space(6.0);
});
}
/// Draw content to reset data.
fn reset_data_ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(4.0);
View::colored_text_button(
ui,
format!("{} {}", TRASH, t!("network_settings.reset_data")),
Colors::red(),
Colors::white_or_black(false),
|| {
Node::reset_data(false);
self.data_reset = true;
},
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("network_settings.reset_data_desc"))
.size(16.0)
.color(Colors::inactive_text()),
);
ui.add_space(4.0);
}
} }
/// Draw button to reset integrated node settings to default values. /// Draw button to reset integrated node settings to default values.
fn reset_settings_ui(ui: &mut egui::Ui) { fn reset_settings_ui(ui: &mut egui::Ui) {
ui.vertical_centered(|ui| { ui.label(
ui.label(RichText::new(t!("network_settings.reset_settings_desc")) RichText::new(t!("network_settings.reset_settings_desc"))
.size(16.0) .size(16.0)
.color(Colors::text(false))); .color(Colors::text(false)),
ui.add_space(8.0); );
let button_text = format!("{} {}", ui.add_space(8.0);
ARROW_COUNTER_CLOCKWISE, let button_text = format!(
t!("network_settings.reset_settings")); "{} {}",
View::action_button(ui, button_text, || { ARROW_COUNTER_CLOCKWISE,
// Show modal to confirm settings reset. t!("network_settings.reset_settings")
Modal::new(RESET_SETTINGS_MODAL) );
.position(ModalPosition::Center) View::action_button(ui, button_text, || {
.title(t!("modal.confirmation")) // Show modal to confirm settings reset.
.show(); Modal::new(RESET_SETTINGS_CONFIRMATION_MODAL)
}); .position(ModalPosition::Center)
.title(t!("confirmation"))
// Show reminder to restart enabled node. .show();
if Node::is_running() { });
ui.add_space(12.0);
ui.label(RichText::new(t!("network_settings.restart_node_required"))
.size(16.0)
.color(Colors::gray())
);
}
ui.add_space(12.0);
});
// Show reminder to restart enabled node.
if Node::is_running() {
ui.add_space(12.0);
ui.label(
RichText::new(t!("network_settings.restart_node_required"))
.size(16.0)
.color(Colors::gray()),
);
}
ui.add_space(10.0);
} }
/// Confirmation to reset settings to default values. /// Confirmation to reset settings to default values.
fn reset_settings_confirmation_modal(ui: &mut egui::Ui, modal: &Modal) { fn reset_settings_confirmation_modal(ui: &mut egui::Ui) {
ui.add_space(6.0); ui.add_space(6.0);
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
let reset_text = format!("{}?", t!("network_settings.reset_settings_desc")); let reset_text = format!("{}?", t!("network_settings.reset_settings_desc"));
ui.label(RichText::new(reset_text) ui.label(
.size(17.0) RichText::new(reset_text)
.color(Colors::text(false))); .size(17.0)
ui.add_space(8.0); .color(Colors::text(false)),
}); );
ui.add_space(8.0);
});
// Show modal buttons. // Show modal buttons.
ui.scope(|ui| { ui.scope(|ui| {
// Setup spacing between buttons. // Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| { ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| { columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("network_settings.reset"), Colors::white_or_black(false), || { View::button(
NodeConfig::reset_to_default(); ui,
modal.close(); t!("network_settings.reset"),
}); Colors::white_or_black(false),
}); || {
columns[1].vertical_centered_justified(|ui| { NodeConfig::reset_to_default();
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { Modal::close();
modal.close(); },
}); );
}); });
}); columns[1].vertical_centered_justified(|ui| {
ui.add_space(6.0); View::button(
}); ui,
} t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Modal::close();
},
);
});
});
ui.add_space(6.0);
});
}
+424 -367
View File
@@ -17,27 +17,24 @@ use egui::{Id, RichText};
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::{CLOCK_COUNTDOWN, GRAPH, TIMER, WATCH}; use crate::gui::icons::{CLOCK_COUNTDOWN, GRAPH, TIMER, WATCH};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, View}; use crate::gui::views::network::NetworkSettings;
use crate::gui::views::network::settings::NetworkSettings; use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::types::{ModalContainer, ModalPosition, TextEditOptions}; use crate::gui::views::{Modal, TextEdit, View};
use crate::node::NodeConfig; use crate::node::NodeConfig;
/// Dandelion server setup section content. /// Dandelion server setup section content.
pub struct DandelionSetup { pub struct DandelionSetup {
/// Epoch duration value in seconds. /// Epoch duration value in seconds.
epoch_edit: String, epoch_edit: String,
/// Embargo expiration time value in seconds to fluff and broadcast if tx not seen on network. /// Embargo expiration time value in seconds to fluff and broadcast if tx not seen on network.
embargo_edit: String, embargo_edit: String,
/// Aggregation period value in seconds. /// Aggregation period value in seconds.
aggregation_edit: String, aggregation_edit: String,
/// Stem phase probability value (stem 90% of the time, fluff 10% of the time by default). /// Stem phase probability value (stem 90% of the time, fluff 10% of the time by default).
stem_prob_edit: String, stem_prob_edit: String,
/// [`Modal`] identifiers allowed at this ui container.
modal_ids: Vec<&'static str>,
} }
/// Identifier epoch duration value [`Modal`]. /// Identifier epoch duration value [`Modal`].
@@ -50,393 +47,453 @@ pub const AGGREGATION_MODAL: &'static str = "aggregation_secs";
pub const STEM_PROBABILITY_MODAL: &'static str = "stem_probability"; pub const STEM_PROBABILITY_MODAL: &'static str = "stem_probability";
impl Default for DandelionSetup { impl Default for DandelionSetup {
fn default() -> Self { fn default() -> Self {
Self { Self {
epoch_edit: NodeConfig::get_dandelion_epoch(), epoch_edit: NodeConfig::get_dandelion_epoch(),
embargo_edit: NodeConfig::get_reorg_cache_period(), embargo_edit: NodeConfig::get_reorg_cache_period(),
aggregation_edit: NodeConfig::get_dandelion_aggregation(), aggregation_edit: NodeConfig::get_dandelion_aggregation(),
stem_prob_edit: NodeConfig::get_stem_probability(), stem_prob_edit: NodeConfig::get_stem_probability(),
modal_ids: vec![ }
EPOCH_MODAL, }
EMBARGO_MODAL,
AGGREGATION_MODAL,
STEM_PROBABILITY_MODAL
]
}
}
} }
impl ModalContainer for DandelionSetup { impl ContentContainer for DandelionSetup {
fn modal_ids(&self) -> &Vec<&'static str> { fn modal_ids(&self) -> Vec<&'static str> {
&self.modal_ids vec![
} EPOCH_MODAL,
EMBARGO_MODAL,
AGGREGATION_MODAL,
STEM_PROBABILITY_MODAL,
]
}
fn modal_ui(&mut self, fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
ui: &mut egui::Ui, match modal.id {
modal: &Modal, EPOCH_MODAL => self.epoch_modal(ui, modal, cb),
cb: &dyn PlatformCallbacks) { EMBARGO_MODAL => self.embargo_modal(ui, modal, cb),
match modal.id { AGGREGATION_MODAL => self.aggregation_modal(ui, modal, cb),
EPOCH_MODAL => self.epoch_modal(ui, modal, cb), STEM_PROBABILITY_MODAL => self.stem_prob_modal(ui, modal, cb),
EMBARGO_MODAL => self.embargo_modal(ui, modal, cb), _ => {}
AGGREGATION_MODAL => self.aggregation_modal(ui, modal, cb), }
STEM_PROBABILITY_MODAL => self.stem_prob_modal(ui, modal, cb), }
_ => {}
} fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) {
} View::sub_title(ui, format!("{} {}", GRAPH, "Dandelion"));
View::horizontal_line(ui, Colors::stroke());
ui.add_space(6.0);
ui.vertical_centered(|ui| {
// Show epoch duration setup.
self.epoch_ui(ui);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
// Show embargo expiration time setup.
self.embargo_ui(ui);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
// Show aggregation period setup.
self.aggregation_ui(ui);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
// Show Stem phase probability setup.
self.stem_prob_ui(ui);
ui.add_space(6.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Show setup to always stem our txs.
let always_stem = NodeConfig::always_stem_our_txs();
View::checkbox(ui, always_stem, t!("network_settings.stem_txs"), || {
NodeConfig::toggle_always_stem_our_txs();
});
ui.add_space(6.0);
});
}
} }
impl DandelionSetup { impl DandelionSetup {
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { /// Draw epoch duration setup content.
// Draw modal content for current ui container. fn epoch_ui(&mut self, ui: &mut egui::Ui) {
self.current_modal_ui(ui, cb); ui.label(
RichText::new(t!("network_settings.epoch_duration"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
View::sub_title(ui, format!("{} {}", GRAPH, "Dandelion")); let epoch = NodeConfig::get_dandelion_epoch();
View::horizontal_line(ui, Colors::stroke()); View::button(
ui.add_space(6.0); ui,
format!("{} {}", WATCH, &epoch),
Colors::white_or_black(false),
|| {
// Setup values for modal.
self.epoch_edit = epoch;
// Show epoch setup modal.
Modal::new(EPOCH_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
},
);
ui.add_space(6.0);
}
ui.vertical_centered(|ui| { /// Draw epoch duration [`Modal`] content.
// Show epoch duration setup. fn epoch_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
self.epoch_ui(ui, cb); // Save button callback.
let on_save = |c: &mut DandelionSetup| {
if let Ok(epoch) = c.epoch_edit.parse::<u16>() {
NodeConfig::save_dandelion_epoch(epoch);
Modal::close();
}
};
ui.add_space(6.0); ui.add_space(6.0);
View::horizontal_line(ui, Colors::item_stroke()); ui.vertical_centered(|ui| {
ui.add_space(6.0); ui.label(
RichText::new(t!("network_settings.epoch_duration"))
.size(17.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
// Show embargo expiration time setup. // Draw epoch text edit.
self.embargo_ui(ui, cb); let mut epoch_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric();
epoch_edit.ui(ui, &mut self.epoch_edit, cb);
if epoch_edit.enter_pressed {
on_save(self);
}
ui.add_space(6.0); // Show error when specified value is not valid or reminder to restart enabled node.
View::horizontal_line(ui, Colors::item_stroke()); if self.epoch_edit.parse::<u16>().is_err() {
ui.add_space(6.0); ui.add_space(12.0);
ui.label(
RichText::new(t!("network_settings.not_valid_value"))
.size(17.0)
.color(Colors::red()),
);
} else {
NetworkSettings::node_restart_required_ui(ui);
}
ui.add_space(12.0);
});
// Show aggregation period setup. // Show modal buttons.
self.aggregation_ui(ui, cb); ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.add_space(6.0); ui.columns(2, |columns| {
View::horizontal_line(ui, Colors::item_stroke()); columns[0].vertical_centered_justified(|ui| {
ui.add_space(6.0); View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
// Close modal.
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
}
// Show Stem phase probability setup. /// Draw embargo expiration time setup content.
self.stem_prob_ui(ui, cb); fn embargo_ui(&mut self, ui: &mut egui::Ui) {
ui.label(
RichText::new(t!("network_settings.embargo_timer"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
ui.add_space(6.0); let embargo = NodeConfig::get_dandelion_embargo();
View::horizontal_line(ui, Colors::item_stroke()); View::button(
ui.add_space(8.0); ui,
format!("{} {}", TIMER, &embargo),
Colors::white_or_black(false),
|| {
self.embargo_edit = embargo;
// Show embargo setup modal.
Modal::new(EMBARGO_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
},
);
ui.add_space(6.0);
}
// Show setup to always stem our txs. /// Draw epoch duration [`Modal`] content.
let always_stem = NodeConfig::always_stem_our_txs(); fn embargo_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
View::checkbox(ui, always_stem, t!("network_settings.stem_txs"), || { // Save button callback.
NodeConfig::toggle_always_stem_our_txs(); let on_save = |c: &mut DandelionSetup| {
}); if let Ok(embargo) = c.embargo_edit.parse::<u16>() {
ui.add_space(6.0); NodeConfig::save_dandelion_embargo(embargo);
}); Modal::close();
} }
};
/// Draw epoch duration setup content. ui.add_space(6.0);
fn epoch_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("network_settings.epoch_duration")) ui.label(
.size(16.0) RichText::new(t!("network_settings.embargo_timer"))
.color(Colors::gray()) .size(17.0)
); .color(Colors::gray()),
ui.add_space(6.0); );
ui.add_space(8.0);
let epoch = NodeConfig::get_dandelion_epoch(); // Draw embargo text edit.
View::button(ui, format!("{} {}", WATCH, epoch.clone()), Colors::button(), || { let mut embargo_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric();
// Setup values for modal. embargo_edit.ui(ui, &mut self.embargo_edit, cb);
self.epoch_edit = epoch; if embargo_edit.enter_pressed {
// Show epoch setup modal. on_save(self);
Modal::new(EPOCH_MODAL) }
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
cb.show_keyboard();
});
ui.add_space(6.0);
}
/// Draw epoch duration [`Modal`] content. // Show error when specified value is not valid or reminder to restart enabled node.
fn epoch_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { if self.embargo_edit.parse::<u16>().is_err() {
ui.add_space(6.0); ui.add_space(12.0);
ui.vertical_centered(|ui| { ui.label(
ui.label(RichText::new(t!("network_settings.epoch_duration")) RichText::new(t!("network_settings.not_valid_value"))
.size(17.0) .size(17.0)
.color(Colors::gray())); .color(Colors::red()),
ui.add_space(8.0); );
} else {
NetworkSettings::node_restart_required_ui(ui);
}
ui.add_space(12.0);
});
// Draw epoch text edit. // Show modal buttons.
let mut epoch_edit_opts = TextEditOptions::new(Id::from(modal.id)).h_center(); ui.scope(|ui| {
View::text_edit(ui, cb, &mut self.epoch_edit, &mut epoch_edit_opts); // Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Show error when specified value is not valid or reminder to restart enabled node. ui.columns(2, |columns| {
if self.epoch_edit.parse::<u16>().is_err() { columns[0].vertical_centered_justified(|ui| {
ui.add_space(12.0); View::button(
ui.label(RichText::new(t!("network_settings.not_valid_value")) ui,
.size(17.0) t!("modal.cancel"),
.color(Colors::red())); Colors::white_or_black(false),
} else { || {
NetworkSettings::node_restart_required_ui(ui); // Close modal.
} Modal::close();
ui.add_space(12.0); },
}); );
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
}
// Show modal buttons. /// Draw aggregation period setup content.
ui.scope(|ui| { fn aggregation_ui(&mut self, ui: &mut egui::Ui) {
// Setup spacing between buttons. ui.label(
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); RichText::new(t!("network_settings.aggregation_period"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
// Save button callback. let ag = NodeConfig::get_dandelion_aggregation();
let on_save = || { View::button(
if let Ok(epoch) = self.epoch_edit.parse::<u16>() { ui,
NodeConfig::save_dandelion_epoch(epoch); format!("{} {}", CLOCK_COUNTDOWN, &ag),
cb.hide_keyboard(); Colors::white_or_black(false),
modal.close(); || {
} // Setup values for modal.
}; self.aggregation_edit = ag;
// Show aggregation setup modal.
Modal::new(AGGREGATION_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
},
);
ui.add_space(6.0);
}
ui.columns(2, |columns| { /// Draw aggregation period [`Modal`] content.
columns[0].vertical_centered_justified(|ui| { fn aggregation_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { // Save button callback.
// Close modal. let on_save = |c: &mut DandelionSetup| {
cb.hide_keyboard(); if let Ok(embargo) = c.aggregation_edit.parse::<u16>() {
modal.close(); NodeConfig::save_dandelion_aggregation(embargo);
}); Modal::close();
}); }
columns[1].vertical_centered_justified(|ui| { };
View::button(ui, t!("modal.save"), Colors::white_or_black(false), on_save);
});
});
ui.add_space(6.0);
});
}
/// Draw embargo expiration time setup content. ui.add_space(6.0);
fn embargo_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("network_settings.embargo_timer")) ui.label(
.size(16.0) RichText::new(t!("network_settings.aggregation_period"))
.color(Colors::gray()) .size(17.0)
); .color(Colors::gray()),
ui.add_space(6.0); );
ui.add_space(8.0);
let embargo = NodeConfig::get_dandelion_embargo(); // Draw aggregation period text edit.
View::button(ui, format!("{} {}", TIMER, embargo.clone()), Colors::button(), || { let mut aggregation_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric();
// Setup values for modal. aggregation_edit.ui(ui, &mut self.aggregation_edit, cb);
self.embargo_edit = embargo; if aggregation_edit.enter_pressed {
// Show embargo setup modal. on_save(self);
Modal::new(EMBARGO_MODAL) }
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
cb.show_keyboard();
});
ui.add_space(6.0);
}
/// Draw epoch duration [`Modal`] content. // Show error when specified value is not valid or reminder to restart enabled node.
fn embargo_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { if self.aggregation_edit.parse::<u16>().is_err() {
ui.add_space(6.0); ui.add_space(12.0);
ui.vertical_centered(|ui| { ui.label(
ui.label(RichText::new(t!("network_settings.embargo_timer")) RichText::new(t!("network_settings.not_valid_value"))
.size(17.0) .size(17.0)
.color(Colors::gray())); .color(Colors::red()),
ui.add_space(8.0); );
} else {
NetworkSettings::node_restart_required_ui(ui);
}
ui.add_space(12.0);
});
// Draw embargo text edit. // Show modal buttons.
let mut embargo_edit_opts = TextEditOptions::new(Id::from(modal.id)).h_center(); ui.scope(|ui| {
View::text_edit(ui, cb, &mut self.embargo_edit, &mut embargo_edit_opts); // Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Show error when specified value is not valid or reminder to restart enabled node. ui.columns(2, |columns| {
if self.embargo_edit.parse::<u16>().is_err() { columns[0].vertical_centered_justified(|ui| {
ui.add_space(12.0); View::button(
ui.label(RichText::new(t!("network_settings.not_valid_value")) ui,
.size(17.0) t!("modal.cancel"),
.color(Colors::red())); Colors::white_or_black(false),
} else { || {
NetworkSettings::node_restart_required_ui(ui); // Close modal.
} Modal::close();
ui.add_space(12.0); },
}); );
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
}
// Show modal buttons. /// Draw stem phase probability setup content.
ui.scope(|ui| { fn stem_prob_ui(&mut self, ui: &mut egui::Ui) {
// Setup spacing between buttons. ui.label(
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); RichText::new(t!("network_settings.stem_probability"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
// Save button callback. let stem_prob = NodeConfig::get_stem_probability();
let on_save = || { View::button(
if let Ok(embargo) = self.embargo_edit.parse::<u16>() { ui,
NodeConfig::save_dandelion_embargo(embargo); format!("{}%", &stem_prob),
cb.hide_keyboard(); Colors::white_or_black(false),
modal.close(); || {
} // Setup values for modal.
}; self.stem_prob_edit = stem_prob;
// Show stem probability setup modal.
Modal::new(STEM_PROBABILITY_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
},
);
ui.add_space(6.0);
}
ui.columns(2, |columns| { /// Draw stem phase probability [`Modal`] content.
columns[0].vertical_centered_justified(|ui| { fn stem_prob_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { // Save button callback.
// Close modal. let on_save = |c: &mut DandelionSetup| {
cb.hide_keyboard(); if let Ok(prob) = c.stem_prob_edit.parse::<u8>() {
modal.close(); NodeConfig::save_stem_probability(prob);
}); Modal::close();
}); }
columns[1].vertical_centered_justified(|ui| { };
View::button(ui, t!("modal.save"), Colors::white_or_black(false), on_save);
});
});
ui.add_space(6.0);
});
}
/// Draw aggregation period setup content. ui.add_space(6.0);
fn aggregation_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("network_settings.aggregation_period")) ui.label(
.size(16.0) RichText::new(t!("network_settings.stem_probability"))
.color(Colors::gray()) .size(17.0)
); .color(Colors::gray()),
ui.add_space(6.0); );
ui.add_space(8.0);
let agg = NodeConfig::get_dandelion_aggregation(); // Draw stem phase probability text edit.
View::button(ui, format!("{} {}", CLOCK_COUNTDOWN, agg.clone()), Colors::button(), || { let mut stem_prob_edit = TextEdit::new(Id::from(modal.id)).h_center().numeric();
// Setup values for modal. stem_prob_edit.ui(ui, &mut self.stem_prob_edit, cb);
self.aggregation_edit = agg; if stem_prob_edit.enter_pressed {
// Show aggregation setup modal. on_save(self);
Modal::new(AGGREGATION_MODAL) }
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
cb.show_keyboard();
});
ui.add_space(6.0);
}
/// Draw aggregation period [`Modal`] content. // Show error when specified value is not valid or reminder to restart enabled node.
fn aggregation_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { if self.stem_prob_edit.parse::<u8>().is_err() {
ui.add_space(6.0); ui.add_space(12.0);
ui.vertical_centered(|ui| { ui.label(
ui.label(RichText::new(t!("network_settings.aggregation_period")) RichText::new(t!("network_settings.not_valid_value"))
.size(17.0) .size(17.0)
.color(Colors::gray())); .color(Colors::red()),
ui.add_space(8.0); );
} else {
NetworkSettings::node_restart_required_ui(ui);
}
ui.add_space(12.0);
});
// Draw aggregation period text edit. // Show modal buttons.
let mut aggregation_edit_opts = TextEditOptions::new(Id::from(modal.id)).h_center(); ui.scope(|ui| {
View::text_edit(ui, cb, &mut self.aggregation_edit, &mut aggregation_edit_opts); // Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Show error when specified value is not valid or reminder to restart enabled node. ui.columns(2, |columns| {
if self.aggregation_edit.parse::<u16>().is_err() { columns[0].vertical_centered_justified(|ui| {
ui.add_space(12.0); View::button(
ui.label(RichText::new(t!("network_settings.not_valid_value")) ui,
.size(17.0) t!("modal.cancel"),
.color(Colors::red())); Colors::white_or_black(false),
} else { || {
NetworkSettings::node_restart_required_ui(ui); // Close modal.
} Modal::close();
ui.add_space(12.0); },
}); );
});
// Show modal buttons. columns[1].vertical_centered_justified(|ui| {
ui.scope(|ui| { View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
// Setup spacing between buttons. on_save(self);
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); });
});
// Save button callback. });
let on_save = || { ui.add_space(6.0);
if let Ok(embargo) = self.aggregation_edit.parse::<u16>() { });
NodeConfig::save_dandelion_aggregation(embargo); }
cb.hide_keyboard(); }
modal.close();
}
};
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
// Close modal.
cb.hide_keyboard();
modal.close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), on_save);
});
});
ui.add_space(6.0);
});
}
/// Draw stem phase probability setup content.
fn stem_prob_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
ui.label(RichText::new(t!("network_settings.stem_probability"))
.size(16.0)
.color(Colors::gray())
);
ui.add_space(6.0);
let stem_prob = NodeConfig::get_stem_probability();
View::button(ui, format!("{}%", stem_prob.clone()), Colors::button(), || {
// Setup values for modal.
self.stem_prob_edit = stem_prob;
// Show stem probability setup modal.
Modal::new(STEM_PROBABILITY_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
cb.show_keyboard();
});
ui.add_space(6.0);
}
/// Draw stem phase probability [`Modal`] content.
fn stem_prob_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("network_settings.stem_probability"))
.size(17.0)
.color(Colors::gray()));
ui.add_space(8.0);
// Draw stem phase probability text edit.
let mut stem_prob_edit_opts = TextEditOptions::new(Id::from(modal.id)).h_center();
View::text_edit(ui, cb, &mut self.stem_prob_edit, &mut stem_prob_edit_opts);
// Show error when specified value is not valid or reminder to restart enabled node.
if self.stem_prob_edit.parse::<u8>().is_err() {
ui.add_space(12.0);
ui.label(RichText::new(t!("network_settings.not_valid_value"))
.size(17.0)
.color(Colors::red()));
} else {
NetworkSettings::node_restart_required_ui(ui);
}
ui.add_space(12.0);
});
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Save button callback.
let on_save = || {
if let Ok(prob) = self.stem_prob_edit.parse::<u8>() {
NodeConfig::save_stem_probability(prob);
cb.hide_keyboard();
modal.close();
}
};
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
// Close modal.
cb.hide_keyboard();
modal.close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), on_save);
});
});
ui.add_space(6.0);
});
}
}
+1 -1
View File
@@ -25,4 +25,4 @@ mod dandelion;
pub use dandelion::DandelionSetup; pub use dandelion::DandelionSetup;
mod stratum; mod stratum;
pub use stratum::StratumSetup; pub use stratum::StratumSetup;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+490 -435
View File
@@ -18,42 +18,39 @@ use grin_chain::SyncStatus;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::{BARBELL, HARD_DRIVES, PLUG, POWER, TIMER}; use crate::gui::icons::{BARBELL, HARD_DRIVES, PLUG, POWER, TIMER};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, View};
use crate::gui::views::network::settings::NetworkSettings; use crate::gui::views::network::settings::NetworkSettings;
use crate::gui::views::types::{ModalContainer, ModalPosition, TextEditOptions}; use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::wallets::modals::WalletsModal; use crate::gui::views::wallets::modals::WalletListModal;
use crate::gui::views::{Modal, TextEdit, View};
use crate::node::{Node, NodeConfig}; use crate::node::{Node, NodeConfig};
use crate::wallet::{WalletConfig, WalletList}; use crate::wallet::{WalletConfig, WalletList};
/// Stratum server setup section content. /// Stratum server setup section content.
pub struct StratumSetup { pub struct StratumSetup {
/// Wallet list to select for mining rewards. /// Wallet list to select for mining rewards.
wallets: WalletList, wallets: WalletList,
/// Wallets [`Modal`] content. /// Wallets [`Modal`] content.
wallets_modal: WalletsModal, wallets_modal: WalletListModal,
/// IP Addresses available at system. /// IP Addresses available at system.
available_ips: Vec<String>, available_ips: Vec<String>,
/// Stratum port value. /// Stratum port value.
stratum_port_edit: String, stratum_port_edit: String,
/// Flag to check if stratum port is available. /// Flag to check if stratum port is available.
stratum_port_available_edit: bool, stratum_port_available_edit: bool,
/// Flag to check if stratum port from saved config value is available. /// Flag to check if stratum port from saved config value is available.
is_port_available: bool, is_port_available: bool,
/// Wallet name to receive rewards. /// Wallet name to receive rewards.
wallet_name: Option<String>, pub wallet_name: Option<String>,
/// Attempt time value in seconds to mine on a particular header. /// Attempt time value in seconds to mine on a particular header.
attempt_time_edit: String, attempt_time_edit: String,
/// Minimum share difficulty value to request from miners. /// Minimum share difficulty value to request from miners.
min_share_diff_edit: String, min_share_diff_edit: String,
/// [`Modal`] identifiers allowed at this ui container.
modal_ids: Vec<&'static str>
} }
/// Identifier for wallet selection [`Modal`]. /// Identifier for wallet selection [`Modal`].
@@ -66,456 +63,514 @@ const ATTEMPT_TIME_MODAL: &'static str = "stratum_attempt_time";
const MIN_SHARE_DIFF_MODAL: &'static str = "stratum_min_share_diff"; const MIN_SHARE_DIFF_MODAL: &'static str = "stratum_min_share_diff";
impl Default for StratumSetup { impl Default for StratumSetup {
fn default() -> Self { fn default() -> Self {
let (ip, port) = NodeConfig::get_stratum_address(); let (ip, port) = NodeConfig::get_stratum_address();
let is_port_available = NodeConfig::is_stratum_port_available(&ip, &port); let is_port_available = NodeConfig::is_stratum_port_available(&ip, &port);
// Setup mining rewards wallet name and identifier. // Setup mining rewards wallet name and identifier.
let mut wallet_id = NodeConfig::get_stratum_wallet_id(); let mut wallet_id = NodeConfig::get_stratum_wallet_id();
let wallet_name = if let Some(id) = wallet_id { let wallet_name = if let Some(id) = wallet_id {
WalletConfig::name_by_id(id) WalletConfig::read_name_by_id(id)
} else { } else {
None None
}; };
if wallet_name.is_none() { if wallet_name.is_none() {
wallet_id = None; wallet_id = None;
} }
Self { Self {
wallets: WalletList::default(), wallets: WalletList::default(),
wallets_modal: WalletsModal::new(wallet_id), wallets_modal: WalletListModal::new(wallet_id, None, false),
available_ips: NodeConfig::get_ip_addrs(), available_ips: NodeConfig::get_ip_addrs(),
stratum_port_edit: port, stratum_port_edit: port,
stratum_port_available_edit: is_port_available, stratum_port_available_edit: is_port_available,
is_port_available, is_port_available,
wallet_name, wallet_name,
attempt_time_edit: NodeConfig::get_stratum_attempt_time(), attempt_time_edit: NodeConfig::get_stratum_attempt_time(),
min_share_diff_edit: NodeConfig::get_stratum_min_share_diff(), min_share_diff_edit: NodeConfig::get_stratum_min_share_diff(),
modal_ids: vec![ }
WALLET_SELECTION_MODAL, }
STRATUM_PORT_MODAL,
ATTEMPT_TIME_MODAL,
MIN_SHARE_DIFF_MODAL
]
}
}
} }
impl ModalContainer for StratumSetup { impl ContentContainer for StratumSetup {
fn modal_ids(&self) -> &Vec<&'static str> { fn modal_ids(&self) -> Vec<&'static str> {
&self.modal_ids vec![
} WALLET_SELECTION_MODAL,
STRATUM_PORT_MODAL,
ATTEMPT_TIME_MODAL,
MIN_SHARE_DIFF_MODAL,
]
}
fn modal_ui(&mut self, fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
ui: &mut egui::Ui, match modal.id {
modal: &Modal, WALLET_SELECTION_MODAL => self.wallets_modal.ui(ui, &mut self.wallets, |wallet, _| {
cb: &dyn PlatformCallbacks) { let id = wallet.get_config().id;
match modal.id { NodeConfig::save_stratum_wallet_id(id);
WALLET_SELECTION_MODAL => self.wallets_modal.ui(ui, modal, &self.wallets, |id| { self.wallet_name = WalletConfig::read_name_by_id(id);
NodeConfig::save_stratum_wallet_id(id); }),
self.wallet_name = WalletConfig::name_by_id(id); STRATUM_PORT_MODAL => self.port_modal(ui, modal, cb),
}), ATTEMPT_TIME_MODAL => self.attempt_modal(ui, modal, cb),
STRATUM_PORT_MODAL => self.port_modal(ui, modal, cb), MIN_SHARE_DIFF_MODAL => self.min_diff_modal(ui, modal, cb),
ATTEMPT_TIME_MODAL => self.attempt_modal(ui, modal, cb), _ => {}
MIN_SHARE_DIFF_MODAL => self.min_diff_modal(ui, modal, cb), }
_ => {} }
}
} fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) {
View::sub_title(
ui,
format!("{} {}", HARD_DRIVES, t!("network_mining.server")),
);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(6.0);
ui.vertical_centered(|ui| {
// Show loading indicator or controls to start/stop stratum server.
if Node::get_sync_status().unwrap_or(SyncStatus::Initial) == SyncStatus::NoSync
&& self.is_port_available
&& self.wallet_name.is_some()
{
if Node::is_stratum_starting() || Node::is_stratum_stopping() {
ui.vertical_centered(|ui| {
ui.add_space(8.0);
View::small_loading_spinner(ui);
ui.add_space(8.0);
});
} else if Node::get_stratum_stats().is_running {
ui.add_space(6.0);
let disable_text = format!("{} {}", POWER, t!("network_settings.disable"));
View::action_button(ui, disable_text, || {
Node::stop_stratum();
let (ip, port) = NodeConfig::get_stratum_address();
self.is_port_available = NodeConfig::is_stratum_port_available(&ip, &port);
});
ui.add_space(6.0);
} else {
ui.add_space(6.0);
let enable_text = format!("{} {}", POWER, t!("network_settings.enable"));
View::action_button(ui, enable_text, || {
Node::start_stratum();
});
ui.add_space(6.0);
}
}
// Show stratum server autorun checkbox.
let stratum_enabled = NodeConfig::is_stratum_autorun_enabled();
View::checkbox(ui, stratum_enabled, t!("network.autorun"), || {
NodeConfig::toggle_stratum_autorun();
});
// Show reminder to restart running server.
if Node::get_stratum_stats().is_running {
ui.add_space(2.0);
ui.label(
RichText::new(t!("network_mining.restart_server_required"))
.size(16.0)
.color(Colors::inactive_text()),
);
}
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Show wallet name.
ui.label(
RichText::new(self.wallet_name.as_ref().unwrap_or(&"-".to_string()))
.size(16.0)
.color(Colors::white_or_black(true)),
);
ui.add_space(8.0);
// Show button to select wallet.
View::button(
ui,
t!("network_settings.choose_wallet"),
Colors::white_or_black(false),
|| {
self.show_wallets_modal();
},
);
ui.add_space(12.0);
if self.wallet_name.is_some() {
ui.label(
RichText::new(t!("network_settings.stratum_wallet_warning"))
.size(16.0)
.color(Colors::inactive_text()),
);
ui.add_space(12.0);
}
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
});
// Show message when IP addresses are not available on the system.
if self.available_ips.is_empty() {
NetworkSettings::no_ip_address_ui(ui);
return;
}
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("network_settings.stratum_ip"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
// Show stratum IP addresses to select.
let (ip, port) = NodeConfig::get_stratum_address();
NetworkSettings::ip_addrs_ui(ui, &ip, &self.available_ips, |selected_ip| {
NodeConfig::save_stratum_address(selected_ip, &port);
self.is_port_available = NodeConfig::is_stratum_port_available(selected_ip, &port);
});
// Show stratum port setup.
self.port_setup_ui(ui);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
// Show attempt time setup.
self.attempt_time_ui(ui);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
// Show minimum acceptable share difficulty setup.
self.min_diff_ui(ui);
});
}
} }
impl StratumSetup { impl StratumSetup {
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { /// Show wallet selection [`Modal`].
// Draw modal content for current ui container. fn show_wallets_modal(&mut self) {
self.current_modal_ui(ui, cb); self.wallets_modal = WalletListModal::new(NodeConfig::get_stratum_wallet_id(), None, false);
// Show modal.
Modal::new(WALLET_SELECTION_MODAL)
.position(ModalPosition::Center)
.title(t!("network_settings.choose_wallet"))
.show();
}
View::sub_title(ui, format!("{} {}", HARD_DRIVES, t!("network_mining.server"))); /// Draw stratum port value setup content.
View::horizontal_line(ui, Colors::stroke()); fn port_setup_ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(6.0); ui.label(
RichText::new(t!("network_settings.stratum_port"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
ui.vertical_centered(|ui| { let (_, port) = NodeConfig::get_stratum_address();
// Show loading indicator or controls to start/stop stratum server. View::button(
if Node::get_sync_status().unwrap_or(SyncStatus::Initial) == SyncStatus::NoSync && ui,
self.is_port_available && self.wallet_name.is_some() { format!("{} {}", PLUG, &port),
if Node::is_stratum_starting() || Node::is_stratum_stopping() { Colors::white_or_black(false),
ui.vertical_centered(|ui| { || {
ui.add_space(8.0); // Setup values for modal.
View::small_loading_spinner(ui); self.stratum_port_edit = port;
ui.add_space(8.0); self.stratum_port_available_edit = self.is_port_available;
}); // Show stratum port modal.
} else if Node::get_stratum_stats().is_running { Modal::new(STRATUM_PORT_MODAL)
ui.add_space(6.0); .position(ModalPosition::CenterTop)
let disable_text = format!("{} {}", POWER, t!("network_settings.disable")); .title(t!("network_settings.change_value"))
View::action_button(ui, disable_text, || { .show();
Node::stop_stratum(); },
let (ip, port) = NodeConfig::get_stratum_address(); );
self.is_port_available = NodeConfig::is_stratum_port_available(&ip, &port); ui.add_space(12.0);
});
ui.add_space(6.0);
} else {
ui.add_space(6.0);
let enable_text = format!("{} {}", POWER, t!("network_settings.enable"));
View::action_button(ui, enable_text, || {
Node::start_stratum();
});
ui.add_space(6.0);
}
}
// Show stratum server autorun checkbox. // Show error when stratum server port is unavailable.
let stratum_enabled = NodeConfig::is_stratum_autorun_enabled(); if !self.is_port_available {
View::checkbox(ui, stratum_enabled, t!("network.autorun"), || { ui.add_space(6.0);
NodeConfig::toggle_stratum_autorun(); ui.label(
}); RichText::new(t!("network_settings.port_unavailable"))
.size(16.0)
.color(Colors::red()),
);
ui.add_space(12.0);
}
}
// Show reminder to restart running server. /// Draw stratum port [`Modal`] content.
if Node::get_stratum_stats().is_running { fn port_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
ui.add_space(2.0); let on_save = |c: &mut StratumSetup| {
ui.label(RichText::new(t!("network_mining.restart_server_required")) // Check if port is available.
.size(16.0) let (stratum_ip, _) = NodeConfig::get_stratum_address();
.color(Colors::inactive_text()) let available =
); NodeConfig::is_stratum_port_available(&stratum_ip, &c.stratum_port_edit);
} c.stratum_port_available_edit = available;
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Show wallet name. // Save port at config if it's available.
ui.label(RichText::new(self.wallet_name.as_ref().unwrap_or(&"-".to_string())) if available {
.size(16.0) NodeConfig::save_stratum_address(&stratum_ip, &c.stratum_port_edit);
.color(Colors::white_or_black(true)));
ui.add_space(8.0);
// Show button to select wallet. c.is_port_available = true;
View::button(ui, t!("network_settings.choose_wallet"), Colors::button(), || { Modal::close();
self.show_wallets_modal(); }
}); };
ui.add_space(12.0);
if self.wallet_name.is_some() { ui.add_space(6.0);
ui.label(RichText::new(t!("network_settings.stratum_wallet_warning")) ui.vertical_centered(|ui| {
.size(16.0) ui.label(
.color(Colors::inactive_text()) RichText::new(t!("network_settings.stratum_port"))
); .size(17.0)
ui.add_space(12.0); .color(Colors::gray()),
} );
View::horizontal_line(ui, Colors::item_stroke()); ui.add_space(8.0);
ui.add_space(6.0);
});
// Show message when IP addresses are not available on the system. // Draw stratum port text edit.
if self.available_ips.is_empty() { let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric();
NetworkSettings::no_ip_address_ui(ui); edit.ui(ui, &mut self.stratum_port_edit, cb);
return; if edit.enter_pressed {
} on_save(self);
}
ui.vertical_centered(|ui| { // Show error when specified port is unavailable.
ui.label(RichText::new(t!("network_settings.stratum_ip")) if !self.stratum_port_available_edit {
.size(16.0) ui.add_space(12.0);
.color(Colors::gray()) ui.label(
); RichText::new(t!("network_settings.port_unavailable"))
ui.add_space(6.0); .size(17.0)
// Show stratum IP addresses to select. .color(Colors::red()),
let (ip, port) = NodeConfig::get_stratum_address(); );
NetworkSettings::ip_addrs_ui(ui, &ip, &self.available_ips, |selected_ip| { } else {
NodeConfig::save_stratum_address(selected_ip, &port); server_restart_required_ui(ui);
self.is_port_available = NodeConfig::is_stratum_port_available(selected_ip, &port); }
}); ui.add_space(12.0);
// Show stratum port setup.
self.port_setup_ui(ui, cb);
View::horizontal_line(ui, Colors::item_stroke()); // Show modal buttons.
ui.add_space(6.0); ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Show attempt time setup. ui.columns(2, |columns| {
self.attempt_time_ui(ui, cb); columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
// Close modal.
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
});
}
View::horizontal_line(ui, Colors::item_stroke()); /// Draw attempt time value setup content.
ui.add_space(6.0); fn attempt_time_ui(&mut self, ui: &mut egui::Ui) {
ui.label(
RichText::new(t!("network_settings.attempt_time"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
// Show minimum acceptable share difficulty setup. let time = NodeConfig::get_stratum_attempt_time();
self.min_diff_ui(ui, cb); View::button(
}); ui,
} format!("{} {}", TIMER, &time),
Colors::white_or_black(false),
|| {
// Setup values for modal.
self.attempt_time_edit = time;
/// Show wallet selection [`Modal`]. // Show attempt time modal.
fn show_wallets_modal(&mut self) { Modal::new(ATTEMPT_TIME_MODAL)
self.wallets_modal = WalletsModal::new(NodeConfig::get_stratum_wallet_id()); .position(ModalPosition::CenterTop)
// Show modal. .title(t!("network_settings.change_value"))
Modal::new(WALLET_SELECTION_MODAL) .show();
.position(ModalPosition::Center) },
.title(t!("network_settings.choose_wallet")) );
.show(); ui.add_space(6.0);
} ui.label(
RichText::new(t!("network_settings.attempt_time_desc"))
.size(16.0)
.color(Colors::inactive_text()),
);
ui.add_space(6.0);
}
/// Draw stratum port value setup content. /// Draw attempt time [`Modal`] content.
fn port_setup_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { fn attempt_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
ui.label(RichText::new(t!("network_settings.stratum_port")) let on_save = |c: &mut StratumSetup| {
.size(16.0) if let Ok(time) = c.attempt_time_edit.parse::<u32>() {
.color(Colors::gray()) NodeConfig::save_stratum_attempt_time(time);
); Modal::close();
ui.add_space(6.0); }
};
let (_, port) = NodeConfig::get_stratum_address(); ui.add_space(6.0);
View::button(ui, format!("{} {}", PLUG, port.clone()), Colors::button(), || { ui.vertical_centered(|ui| {
// Setup values for modal. ui.label(
self.stratum_port_edit = port; RichText::new(t!("network_settings.attempt_time"))
self.stratum_port_available_edit = self.is_port_available; .size(17.0)
// Show stratum port modal. .color(Colors::gray()),
Modal::new(STRATUM_PORT_MODAL) );
.position(ModalPosition::CenterTop) ui.add_space(8.0);
.title(t!("network_settings.change_value"))
.show();
cb.show_keyboard();
});
ui.add_space(12.0);
// Show error when stratum server port is unavailable. // Draw attempt time text edit.
if !self.is_port_available { let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric();
ui.add_space(6.0); edit.ui(ui, &mut self.attempt_time_edit, cb);
ui.label(RichText::new(t!("network_settings.port_unavailable")) if edit.enter_pressed {
.size(16.0) on_save(self);
.color(Colors::red())); }
ui.add_space(12.0);
}
}
/// Draw stratum port [`Modal`] content. // Show error when specified value is not valid or reminder to restart enabled node.
fn port_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { if self.attempt_time_edit.parse::<u32>().is_err() {
ui.add_space(6.0); ui.add_space(12.0);
ui.vertical_centered(|ui| { ui.label(
ui.label(RichText::new(t!("network_settings.stratum_port")) RichText::new(t!("network_settings.not_valid_value"))
.size(17.0) .size(17.0)
.color(Colors::gray())); .color(Colors::red()),
ui.add_space(8.0); );
} else {
server_restart_required_ui(ui);
}
ui.add_space(12.0);
});
// Draw stratum port text edit. // Show modal buttons.
let mut text_edit_opts = TextEditOptions::new(Id::from(modal.id)).h_center(); ui.scope(|ui| {
View::text_edit(ui, cb, &mut self.stratum_port_edit, &mut text_edit_opts); // Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Show error when specified port is unavailable. ui.columns(2, |columns| {
if !self.stratum_port_available_edit { columns[0].vertical_centered_justified(|ui| {
ui.add_space(12.0); View::button(
ui.label(RichText::new(t!("network_settings.port_unavailable")) ui,
.size(17.0) t!("modal.cancel"),
.color(Colors::red())); Colors::white_or_black(false),
} else { || {
server_restart_required_ui(ui); // Close modal.
} Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
}
ui.add_space(12.0); /// Draw minimum share difficulty value setup content.
fn min_diff_ui(&mut self, ui: &mut egui::Ui) {
ui.label(
RichText::new(t!("network_settings.min_share_diff"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(6.0);
// Show modal buttons. let diff = NodeConfig::get_stratum_min_share_diff();
ui.scope(|ui| { View::button(
// Setup spacing between buttons. ui,
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); format!("{} {}", BARBELL, &diff),
Colors::white_or_black(false),
|| {
// Setup values for modal.
self.min_share_diff_edit = diff;
// Save button callback. // Show share difficulty setup modal.
let on_save = || { Modal::new(MIN_SHARE_DIFF_MODAL)
// Check if port is available. .position(ModalPosition::CenterTop)
let (stratum_ip, _) = NodeConfig::get_stratum_address(); .title(t!("network_settings.change_value"))
let available = NodeConfig::is_stratum_port_available( .show();
&stratum_ip, },
&self.stratum_port_edit );
); ui.add_space(6.0);
self.stratum_port_available_edit = available; }
// Save port at config if it's available. /// Draw minimum acceptable share difficulty [`Modal`] content.
if available { fn min_diff_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
NodeConfig::save_stratum_address(&stratum_ip, &self.stratum_port_edit); let on_save = |c: &mut StratumSetup| {
if let Ok(diff) = c.min_share_diff_edit.parse::<u64>() {
NodeConfig::save_stratum_min_share_diff(diff);
Modal::close();
}
};
self.is_port_available = true; ui.add_space(6.0);
cb.hide_keyboard(); ui.vertical_centered(|ui| {
modal.close(); ui.label(
} RichText::new(t!("network_settings.min_share_diff"))
}; .size(17.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
ui.columns(2, |columns| { // Draw share difficulty text edit.
columns[0].vertical_centered_justified(|ui| { let mut edit = TextEdit::new(Id::from(modal.id)).h_center().numeric();
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { edit.ui(ui, &mut self.min_share_diff_edit, cb);
// Close modal. if edit.enter_pressed {
cb.hide_keyboard(); on_save(self);
modal.close(); }
});
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), on_save);
});
});
ui.add_space(6.0);
});
});
}
/// Draw attempt time value setup content. // Show error when specified value is not valid or reminder to restart enabled node.
fn attempt_time_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { if self.min_share_diff_edit.parse::<u64>().is_err() {
ui.label(RichText::new(t!("network_settings.attempt_time")) ui.add_space(12.0);
.size(16.0) ui.label(
.color(Colors::gray()) RichText::new(t!("network_settings.not_valid_value"))
); .size(17.0)
ui.add_space(6.0); .color(Colors::red()),
);
} else {
server_restart_required_ui(ui);
}
ui.add_space(12.0);
});
let time = NodeConfig::get_stratum_attempt_time(); // Show modal buttons.
View::button(ui, format!("{} {}", TIMER, time.clone()), Colors::button(), || { ui.scope(|ui| {
// Setup values for modal. // Setup spacing between buttons.
self.attempt_time_edit = time; ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Show attempt time modal. ui.columns(2, |columns| {
Modal::new(ATTEMPT_TIME_MODAL) columns[0].vertical_centered_justified(|ui| {
.position(ModalPosition::CenterTop) View::button(
.title(t!("network_settings.change_value")) ui,
.show(); t!("modal.cancel"),
cb.show_keyboard(); Colors::white_or_black(false),
}); || {
ui.add_space(6.0); // Close modal.
ui.label(RichText::new(t!("network_settings.attempt_time_desc")) Modal::close();
.size(16.0) },
.color(Colors::inactive_text()) );
); });
ui.add_space(6.0); columns[1].vertical_centered_justified(|ui| {
} View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
/// Draw attempt time [`Modal`] content. });
fn attempt_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { });
ui.add_space(6.0); });
ui.vertical_centered(|ui| { ui.add_space(6.0);
ui.label(RichText::new(t!("network_settings.attempt_time")) });
.size(17.0) }
.color(Colors::gray()));
ui.add_space(8.0);
// Draw attempt time text edit.
let mut text_edit_opts = TextEditOptions::new(Id::from(modal.id)).h_center();
View::text_edit(ui, cb, &mut self.attempt_time_edit, &mut text_edit_opts);
// Show error when specified value is not valid or reminder to restart enabled node.
if self.attempt_time_edit.parse::<u32>().is_err() {
ui.add_space(12.0);
ui.label(RichText::new(t!("network_settings.not_valid_value"))
.size(17.0)
.color(Colors::red()));
} else {
server_restart_required_ui(ui);
}
ui.add_space(12.0);
});
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Save button callback.
let on_save = || {
if let Ok(time) = self.attempt_time_edit.parse::<u32>() {
NodeConfig::save_stratum_attempt_time(time);
cb.hide_keyboard();
modal.close();
}
};
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
// Close modal.
cb.hide_keyboard();
modal.close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), on_save);
});
});
ui.add_space(6.0);
});
}
/// Draw minimum share difficulty value setup content.
fn min_diff_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
ui.label(RichText::new(t!("network_settings.min_share_diff"))
.size(16.0)
.color(Colors::gray())
);
ui.add_space(6.0);
let diff = NodeConfig::get_stratum_min_share_diff();
View::button(ui, format!("{} {}", BARBELL, diff.clone()), Colors::button(), || {
// Setup values for modal.
self.min_share_diff_edit = diff;
// Show share difficulty setup modal.
Modal::new(MIN_SHARE_DIFF_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("network_settings.change_value"))
.show();
cb.show_keyboard();
});
ui.add_space(6.0);
}
/// Draw minimum acceptable share difficulty [`Modal`] content.
fn min_diff_modal(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("network_settings.min_share_diff"))
.size(17.0)
.color(Colors::gray()));
ui.add_space(8.0);
// Draw share difficulty text edit.
let mut text_edit_opts = TextEditOptions::new(Id::from(modal.id)).h_center();
View::text_edit(ui, cb, &mut self.min_share_diff_edit, &mut text_edit_opts);
// Show error when specified value is not valid or reminder to restart enabled node.
if self.min_share_diff_edit.parse::<u64>().is_err() {
ui.add_space(12.0);
ui.label(RichText::new(t!("network_settings.not_valid_value"))
.size(17.0)
.color(Colors::red()));
} else {
server_restart_required_ui(ui);
}
ui.add_space(12.0);
});
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
// Save button callback.
let on_save = || {
if let Ok(diff) = self.min_share_diff_edit.parse::<u64>() {
NodeConfig::save_stratum_min_share_diff(diff);
cb.hide_keyboard();
modal.close();
}
};
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
// Close modal.
cb.hide_keyboard();
modal.close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), on_save);
});
});
ui.add_space(6.0);
});
}
} }
/// Reminder to restart enabled node to show on edit setting at [`Modal`]. /// Reminder to restart enabled node to show on edit setting at [`Modal`].
pub fn server_restart_required_ui(ui: &mut egui::Ui) { pub fn server_restart_required_ui(ui: &mut egui::Ui) {
if Node::get_stratum_stats().is_running { if Node::get_stratum_stats().is_running {
ui.add_space(12.0); ui.add_space(12.0);
ui.label(RichText::new(t!("network_mining.restart_server_required")) ui.label(
.size(16.0) RichText::new(t!("network_mining.restart_server_required"))
.color(Colors::green()) .size(16.0)
); .color(Colors::green()),
} );
} }
}
+30 -20
View File
@@ -13,29 +13,39 @@
// limitations under the License. // limitations under the License.
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use serde_derive::{Deserialize, Serialize};
/// Network tab content interface. /// Integrated node tab content interface.
pub trait NetworkTab { pub trait NodeTab {
fn get_type(&self) -> NetworkTabType; fn get_type(&self) -> NodeTabType;
fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks); fn tab_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks);
} }
/// Type of [`NetworkTab`] content. /// Type of [`NodeTab`] content.
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum NetworkTabType { pub enum NodeTabType {
Node, Info,
Metrics, Metrics,
Mining, Mining,
Settings Settings,
} }
impl NetworkTabType { impl NodeTabType {
pub fn title(&self) -> String { pub fn title(&self) -> String {
match *self { match *self {
NetworkTabType::Node => { t!("network.node") } NodeTabType::Info => t!("network.node").into(),
NetworkTabType::Metrics => { t!("network.metrics") } NodeTabType::Metrics => t!("network.metrics").into(),
NetworkTabType::Mining => { t!("network.mining") } NodeTabType::Mining => t!("network.mining").into(),
NetworkTabType::Settings => { t!("network.settings") } NodeTabType::Settings => t!("network.settings").into(),
} }
} }
} }
/// Connection details to share.
#[derive(Serialize, Deserialize, Clone)]
pub struct ShareConnection {
#[serde(rename(serialize = "ipPort", deserialize = "ipPort"))]
pub url: String,
pub username: String,
pub secret: String,
}
+297 -286
View File
@@ -12,359 +12,370 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::epaint::{Pos2, Shape, Stroke, emath::lerp, vec2};
use egui::scroll_area::ScrollAreaOutput; use egui::scroll_area::ScrollAreaOutput;
use egui::{Sense, Align2, Area, Color32, Id, Rect, Response, Widget, Vec2}; use egui::{Align2, Area, Color32, Id, Rect, Response, Sense, UiBuilder, Vec2, Widget};
use egui::epaint::{emath::lerp, vec2, Pos2, Shape, Stroke};
/// A spinner widget used to indicate loading. /// A spinner widget used to indicate loading.
/// This was taken from egui and modified slightly to allow passing a progress value /// This was taken from egui and modified slightly to allow passing a progress value
#[must_use = "You should put this widget in an ui with `ui.add(widget);`"] #[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
#[derive(Default)] #[derive(Default)]
pub struct ProgressSpinner { pub struct ProgressSpinner {
/// Uses the style's `interact_size` if `None`. /// Uses the style's `interact_size` if `None`.
size: Option<f32>, size: Option<f32>,
color: Option<Color32>, color: Option<Color32>,
progress: Option<f64>, progress: Option<f64>,
} }
impl ProgressSpinner { impl ProgressSpinner {
/// Create a new spinner that uses the style's `interact_size` unless changed. /// Create a new spinner that uses the style's `interact_size` unless changed.
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// Sets the spinner's size. The size sets both the height and width, as the spinner is always /// Sets the spinner's size. The size sets both the height and width, as the spinner is always
/// square. If the size isn't set explicitly, the active style's `interact_size` is used. /// square. If the size isn't set explicitly, the active style's `interact_size` is used.
#[allow(unused)] #[allow(unused)]
pub fn size(mut self, size: f32) -> Self { pub fn size(mut self, size: f32) -> Self {
self.size = Some(size); self.size = Some(size);
self self
} }
/// Sets the spinner's color. /// Sets the spinner's color.
pub fn color(mut self, color: impl Into<Color32>) -> Self { pub fn color(mut self, color: impl Into<Color32>) -> Self {
self.color = Some(color.into()); self.color = Some(color.into());
self self
} }
/// Sets the spinner's progress. /// Sets the spinner's progress.
/// Should be in the range `[0.0, 1.0]`. /// Should be in the range `[0.0, 1.0]`.
pub fn progress(mut self, progress: impl Into<Option<f64>>) -> Self { pub fn progress(mut self, progress: impl Into<Option<f64>>) -> Self {
self.progress = progress.into(); self.progress = progress.into();
self self
} }
/// Paint the spinner in the given rectangle. /// Paint the spinner in the given rectangle.
pub fn paint_at(&self, ui: &egui::Ui, rect: Rect) { pub fn paint_at(&self, ui: &egui::Ui, rect: Rect) {
if ui.is_rect_visible(rect) { if ui.is_rect_visible(rect) {
ui.ctx().request_repaint(); // because it is animated ui.ctx().request_repaint(); // because it is animated
let color = self let color = self
.color .color
.unwrap_or_else(|| ui.visuals().strong_text_color()); .unwrap_or_else(|| ui.visuals().strong_text_color());
let radius = (rect.height() / 2.0) - 2.0; let radius = (rect.height() / 2.0) - 2.0;
let n_points = 20; let n_points = 20;
let (start_angle, end_angle) = if let Some(progress) = self.progress { let (start_angle, end_angle) = if let Some(progress) = self.progress {
let start_angle = 0f64.to_radians(); let start_angle = 0f64.to_radians();
let end_angle = start_angle + 360f64.to_radians() * progress; let end_angle = start_angle + 360f64.to_radians() * progress;
(start_angle, end_angle) (start_angle, end_angle)
} else { } else {
let time = ui.input(|i| i.time); let time = ui.input(|i| i.time);
let start_angle = time * std::f64::consts::TAU; let start_angle = time * std::f64::consts::TAU;
let end_angle = start_angle + 240f64.to_radians() * time.sin(); let end_angle = start_angle + 240f64.to_radians() * time.sin();
(start_angle, end_angle) (start_angle, end_angle)
}; };
let points: Vec<Pos2> = (0..=n_points) let points: Vec<Pos2> = (0..=n_points)
.map(|i| { .map(|i| {
let angle = lerp(start_angle..=end_angle, i as f64 / n_points as f64); let angle = lerp(start_angle..=end_angle, i as f64 / n_points as f64);
let (sin, cos) = angle.sin_cos(); let (sin, cos) = angle.sin_cos();
rect.center() + radius * vec2(cos as f32, sin as f32) rect.center() + radius * vec2(cos as f32, sin as f32)
}) })
.collect(); .collect();
ui.painter() ui.painter()
.add(Shape::line(points, Stroke::new(3.0, color))); .add(Shape::line(points, Stroke::new(3.0, color)));
} }
} }
} }
impl Widget for ProgressSpinner { impl Widget for ProgressSpinner {
fn ui(self, ui: &mut egui::Ui) -> Response { fn ui(self, ui: &mut egui::Ui) -> Response {
let size = self let size = self
.size .size
.unwrap_or_else(|| ui.style().spacing.interact_size.y); .unwrap_or_else(|| ui.style().spacing.interact_size.y);
let (rect, response) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); let (rect, response) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
self.paint_at(ui, rect); self.paint_at(ui, rect);
response response
} }
} }
/// The current state of the pull to refresh widget. /// The current state of the pull to refresh widget.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum PullToRefreshState { pub enum PullToRefreshState {
/// The widget is idle, no refresh is happening. /// The widget is idle, no refresh is happening.
Idle, Idle,
/// The user is dragging. /// The user is dragging.
Dragging { Dragging {
/// `distance` is the distance the user dragged. /// `distance` is the distance the user dragged.
distance: f32, distance: f32,
/// `far_enough` is true if the user dragged far enough to trigger a refresh. /// `far_enough` is true if the user dragged far enough to trigger a refresh.
far_enough: bool, far_enough: bool,
}, },
/// The user dragged far enough to trigger a refresh and released the pointer. /// The user dragged far enough to trigger a refresh.
DoRefresh, DoRefresh,
/// The refresh is currently happening. /// The refresh is currently happening.
Refreshing, Refreshing,
} }
impl PullToRefreshState { impl PullToRefreshState {
fn progress(&self, min_distance: f32) -> Option<f64> { fn progress(&self, min_distance: f32) -> Option<f64> {
match self { match self {
PullToRefreshState::Idle => Some(0.0), PullToRefreshState::Idle => Some(0.0),
PullToRefreshState::Dragging { distance, .. } => { PullToRefreshState::Dragging { distance, .. } => {
Some((distance / min_distance).min(1.0).max(0.0) as f64) Some((distance / min_distance).min(1.0).max(0.0) as f64)
} }
PullToRefreshState::DoRefresh => Some(1.0), PullToRefreshState::DoRefresh => Some(1.0),
PullToRefreshState::Refreshing => None, PullToRefreshState::Refreshing => None,
} }
} }
} }
/// The response of the pull to refresh widget. /// The response of the pull to refresh widget.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PullToRefreshResponse<T> { pub struct PullToRefreshResponse<T> {
/// Current state of the pull to refresh widget. /// Current state of the pull to refresh widget.
pub state: PullToRefreshState, pub state: PullToRefreshState,
/// The inner response of the widget you wrapped in [`PullToRefresh::ui`] or [`PullToRefresh::scroll_area_ui`]. /// The inner response of the widget you wrapped in [`PullToRefresh::ui`] or [`PullToRefresh::scroll_area_ui`].
pub inner: T, pub inner: T,
} }
impl<T> PullToRefreshResponse<T> { impl<T> PullToRefreshResponse<T> {
/// Returns true if the user dragged far enough to trigger a refresh. /// Returns true if the user dragged far enough to trigger a refresh.
pub fn should_refresh(&self) -> bool { pub fn should_refresh(&self) -> bool {
matches!(self.state, PullToRefreshState::DoRefresh) matches!(self.state, PullToRefreshState::DoRefresh)
} }
} }
/// A widget that allows the user to pull to refresh. /// A widget that allows the user to pull to refresh.
pub struct PullToRefresh { pub struct PullToRefresh {
id: Id, id: Id,
loading: bool, loading: bool,
min_refresh_distance: f32, min_refresh_distance: f32,
can_refresh: bool, can_refresh: bool,
} }
impl PullToRefresh { impl PullToRefresh {
/// Creates a new pull to refresh widget. /// Creates a new pull to refresh widget.
/// If `loading` is true, the widget will show the loading indicator. /// If `loading` is true, the widget will show the loading indicator.
pub fn new(loading: bool) -> Self { pub fn new(loading: bool) -> Self {
Self { Self {
id: Id::new("pull_to_refresh"), id: Id::new("pull_to_refresh"),
loading, loading,
min_refresh_distance: 100.0, min_refresh_distance: 100.0,
can_refresh: true, can_refresh: true,
} }
} }
/// Sets the minimum distance the user needs to drag to trigger a refresh. /// Sets the minimum distance the user needs to drag to trigger a refresh.
pub fn min_refresh_distance(mut self, min_refresh_distance: f32) -> Self { pub fn min_refresh_distance(mut self, min_refresh_distance: f32) -> Self {
self.min_refresh_distance = min_refresh_distance; self.min_refresh_distance = min_refresh_distance;
self self
} }
/// You need to provide a id if you use multiple pull to refresh widgets at once. /// You need to provide a id if you use multiple pull to refresh widgets at once.
pub fn id(mut self, id: Id) -> Self { pub fn id(mut self, id: Id) -> Self {
self.id = id; self.id = id;
self self
} }
/// If `can_refresh` is false, pulling will not trigger a refresh. /// If `can_refresh` is false, pulling will not trigger a refresh.
pub fn can_refresh(mut self, can_refresh: bool) -> Self { pub fn can_refresh(mut self, can_refresh: bool) -> Self {
self.can_refresh = can_refresh; self.can_refresh = can_refresh;
self self
} }
/// Shows the pull to refresh widget. /// Shows the pull to refresh widget.
/// Note: If you want to use the pull to refresh widget in a scroll area, use [`Self::scroll_area_ui`]. /// Note: If you want to use the pull to refresh widget in a scroll area, use [`Self::scroll_area_ui`].
/// You might want to disable text selection via [`egui::style::Interaction`] /// You might want to disable text selection via [`egui::style::Interaction`]
/// to avoid conflicts with the drag gesture. /// to avoid conflicts with the drag gesture.
pub fn ui<T>( pub fn ui<T>(
self, self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
content: impl FnOnce(&mut egui::Ui) -> T, content: impl FnOnce(&mut egui::Ui) -> T,
) -> PullToRefreshResponse<T> { ) -> PullToRefreshResponse<T> {
let mut child = ui.child_ui(ui.available_rect_before_wrap(), *ui.layout(), None); let mut child = ui.new_child(
UiBuilder::new()
.max_rect(ui.available_rect_before_wrap())
.layout(*ui.layout()),
);
let output = content(&mut child); let output = content(&mut child);
let can_refresh = self.can_refresh; let can_refresh = self.can_refresh;
let state = self.internal_ui(ui, can_refresh, None, child.min_rect()); let state = self.internal_ui(ui, can_refresh, None, child.min_rect());
PullToRefreshResponse { PullToRefreshResponse {
state, state,
inner: output, inner: output,
} }
} }
/// Shows the pull to refresh widget, wrapping a [egui::ScrollArea]. /// Shows the pull to refresh widget, wrapping a [egui::ScrollArea].
/// Pass the output of the scroll area to the content function. /// Pass the output of the scroll area to the content function.
pub fn scroll_area_ui<T>( pub fn scroll_area_ui<T>(
self, self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
content: impl FnOnce(&mut egui::Ui) -> ScrollAreaOutput<T>, content: impl FnOnce(&mut egui::Ui) -> ScrollAreaOutput<T>,
) -> PullToRefreshResponse<ScrollAreaOutput<T>> { ) -> PullToRefreshResponse<ScrollAreaOutput<T>> {
let scroll_output = content(ui); let scroll_output = content(ui);
let content_rect = scroll_output.inner_rect; let content_rect = scroll_output.inner_rect;
let can_refresh = scroll_output.state.offset.y == 0.0 && self.can_refresh; let can_refresh = scroll_output.state.offset.y == 0.0 && self.can_refresh;
// This is the id used in the Sense of the scroll area // This is the id used in the Sense of the scroll area
// I hope this id is stable across egui patches... // I hope this id is stable across egui patches...
let allow_dragged_id = scroll_output.id.with("area"); let allow_dragged_id = scroll_output.id.with("area");
let state = self.internal_ui(ui, can_refresh, Some(allow_dragged_id), content_rect); let state = self.internal_ui(ui, can_refresh, Some(allow_dragged_id), content_rect);
PullToRefreshResponse { PullToRefreshResponse {
state, state,
inner: scroll_output, inner: scroll_output,
} }
} }
fn internal_ui( fn internal_ui(
self, self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
can_refresh: bool, can_refresh: bool,
allow_dragged_id: Option<Id>, allow_dragged_id: Option<Id>,
content_rect: Rect, content_rect: Rect,
) -> PullToRefreshState { ) -> PullToRefreshState {
let last_state = ui.data_mut(|data| { let last_state = ui.data_mut(|data| {
data.get_temp_mut_or(self.id, PullToRefreshState::Idle) data.get_temp_mut_or(self.id, PullToRefreshState::Idle)
.clone() .clone()
}); });
let mut state = last_state; let mut state = last_state;
if self.loading { if self.loading {
state = PullToRefreshState::Refreshing; state = PullToRefreshState::Refreshing;
} }
if !self.loading && matches!(state, PullToRefreshState::Refreshing) { if !self.loading && matches!(state, PullToRefreshState::Refreshing) {
state = PullToRefreshState::Idle; state = PullToRefreshState::Idle;
} }
if can_refresh && !self.loading { if can_refresh && !self.loading {
let sense = ui.interact(content_rect, self.id, Sense::hover()); let sense = ui.interact(content_rect, self.id, Sense::hover());
let is_something_blocking_drag = ui.ctx().dragged_id().is_some() let is_something_blocking_drag = ui.ctx().dragged_id().is_some()
&& !allow_dragged_id.map_or(false, |id| ui.ctx().is_being_dragged(id)); && !allow_dragged_id.map_or(false, |id| ui.ctx().is_being_dragged(id));
if sense.contains_pointer() && !is_something_blocking_drag { if sense.contains_pointer() && !is_something_blocking_drag {
let (delta, any_released) = ui.input(|input| { let (delta, any_released) = ui.input(|input| {
( (
if input.pointer.is_decidedly_dragging() { if input.pointer.is_decidedly_dragging() {
Some(input.pointer.delta()) Some(input.pointer.delta())
} else { } else {
None None
}, },
input.pointer.any_released(), input.pointer.any_released(),
) )
}); });
if let Some(delta) = delta { if let Some(delta) = delta {
if matches!(state, PullToRefreshState::Idle) { if matches!(state, PullToRefreshState::Idle) {
state = PullToRefreshState::Dragging { state = PullToRefreshState::Dragging {
distance: 0.0, distance: 0.0,
far_enough: false, far_enough: false,
}; };
} }
if let PullToRefreshState::Dragging { distance: drag, .. } = state.clone() { if let PullToRefreshState::Dragging { distance: drag, .. } = state.clone() {
let dist = drag + delta.y; let dist = drag + delta.y;
state = PullToRefreshState::Dragging { state = PullToRefreshState::Dragging {
distance: dist, distance: dist,
far_enough: dist > self.min_refresh_distance, far_enough: dist > self.min_refresh_distance,
}; };
} }
} else { } else {
state = PullToRefreshState::Idle; state = PullToRefreshState::Idle;
} }
if any_released { if any_released {
if let PullToRefreshState::Dragging { if let PullToRefreshState::Dragging {
far_enough: enough, .. far_enough: enough, ..
} = state.clone() } = state.clone()
{ {
if enough { if enough {
state = PullToRefreshState::DoRefresh; state = PullToRefreshState::DoRefresh;
} else { } else {
state = PullToRefreshState::Idle; state = PullToRefreshState::Idle;
} }
} else { } else {
state = PullToRefreshState::Idle; state = PullToRefreshState::Idle;
} }
} } else if let PullToRefreshState::Dragging {
} else { far_enough: enough, ..
state = PullToRefreshState::Idle; } = state.clone()
} {
} else { if enough {
state = PullToRefreshState::Idle; state = PullToRefreshState::DoRefresh;
} }
}
} else {
state = PullToRefreshState::Idle;
}
} else {
state = PullToRefreshState::Idle;
}
if self.loading { if self.loading {
state = PullToRefreshState::Refreshing; state = PullToRefreshState::Refreshing;
} }
let spinner_size = Vec2::splat(24.0); let spinner_size = Vec2::splat(24.0);
let progress_for_offset = match &state { let progress_for_offset = match &state {
PullToRefreshState::Idle => 0.0, PullToRefreshState::Idle => 0.0,
PullToRefreshState::Dragging { .. } => { PullToRefreshState::Dragging { .. } => {
state.progress(self.min_refresh_distance).unwrap_or(1.0) state.progress(self.min_refresh_distance).unwrap_or(1.0)
} }
PullToRefreshState::DoRefresh => 1.0, PullToRefreshState::DoRefresh => 1.0,
PullToRefreshState::Refreshing => 1.0, PullToRefreshState::Refreshing => 1.0,
} as f32; } as f32;
let anim_progress = ui.ctx().animate_value_with_time( let anim_progress = ui.ctx().animate_value_with_time(
self.id.with("offset_top"), self.id.with("offset_top"),
progress_for_offset, progress_for_offset,
ui.style().animation_time, ui.style().animation_time,
); );
let offset_top = -spinner_size.y + spinner_size.y * anim_progress * 2.0; let offset_top = -spinner_size.y + spinner_size.y * anim_progress * 2.0;
if anim_progress > 0.0 { if anim_progress > 0.0 {
Area::new(Id::new("Pull to refresh indicator")) Area::new(Id::new("Pull to refresh indicator"))
.fixed_pos(content_rect.center_top()) .fixed_pos(content_rect.center_top())
.pivot(Align2::CENTER_TOP) .pivot(Align2::CENTER_TOP)
.show(ui.ctx(), |ui| { .show(ui.ctx(), |ui| {
let (rect, _) = ui.allocate_exact_size(spinner_size, Sense::hover()); let (rect, _) = ui.allocate_exact_size(spinner_size, Sense::hover());
ui.set_clip_rect(Rect::everything_below(rect.min.y)); ui.set_clip_rect(Rect::everything_below(rect.min.y));
let rect = rect.translate(Vec2::new(0.0, offset_top)); let rect = rect.translate(Vec2::new(0.0, offset_top));
ui.painter().circle( ui.painter().circle(
rect.center(), rect.center(),
spinner_size.x / 1.5, spinner_size.x / 1.5,
ui.style().visuals.widgets.inactive.bg_fill, ui.style().visuals.widgets.inactive.bg_fill,
ui.visuals().widgets.inactive.bg_stroke, ui.visuals().widgets.inactive.bg_stroke,
); );
let mut spinner_color = ui.style().visuals.widgets.inactive.fg_stroke.color; let mut spinner_color = ui.style().visuals.widgets.inactive.fg_stroke.color;
if anim_progress < 1.0 { if anim_progress < 1.0 {
spinner_color = Color32::from_rgba_unmultiplied( spinner_color = Color32::from_rgba_unmultiplied(
spinner_color.r(), spinner_color.r(),
spinner_color.g(), spinner_color.g(),
spinner_color.b(), spinner_color.b(),
(spinner_color.a() as f32 * 0.7).round() as u8, (spinner_color.a() as f32 * 0.7).round() as u8,
); );
} }
ProgressSpinner::new() ProgressSpinner::new()
.color(spinner_color) .color(spinner_color)
.progress(state.progress(self.min_refresh_distance)) .progress(state.progress(self.min_refresh_distance))
.paint_at(ui, rect); .paint_at(ui, rect);
}); });
} }
ui.data_mut(|data| { ui.data_mut(|data| {
data.insert_temp(self.id, state.clone()); data.insert_temp(self.id, state.clone());
}); });
state state
} }
} }
+464 -376
View File
@@ -12,426 +12,514 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::epaint::RectShape;
use egui::{SizeHint, TextureHandle, UiBuilder};
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{ExtendedColorType, ImageEncoder};
use parking_lot::RwLock;
use qrcodegen::QrCode;
use std::mem::size_of; use std::mem::size_of;
use std::sync::Arc; use std::sync::Arc;
use parking_lot::RwLock;
use std::thread; use std::thread;
use egui::{SizeHint, TextureHandle};
use egui::epaint::RectShape;
use image::{ExtendedColorType, ImageEncoder};
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use qrcodegen::QrCode;
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::IMAGES_SQUARE; use crate::gui::icons::{COPY, IMAGES_SQUARE};
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::QrImageState;
use crate::gui::views::View; use crate::gui::views::View;
use crate::gui::views::types::QrImageState;
/// QR code image from text. /// QR code image from text.
pub struct QrCodeContent { pub struct QrCodeContent {
/// Text to create QR code. /// QR code text.
pub(crate) text: String, pub text: String,
/// Flag to show text below QR code.
show_text: bool,
/// Flag to copy text below QR code.
can_copy_text: bool,
/// Flag to draw animated QR with Uniform Resources /// Maximum QR code size.
/// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md max_size: f32,
animated: bool,
/// Index of current image at animation.
animated_index: Option<usize>,
/// Time of last image draw.
animation_time: Option<i64>,
/// Texture handle to show image when created. /// Flag to draw animated QR with Uniform Resources
texture_handle: Option<TextureHandle>, /// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md
/// QR code view data state. animated: bool,
qr_image_state: Arc<RwLock<QrImageState>>, /// Index of current image at animation.
animated_index: Option<usize>,
/// Time of last image draw.
animation_time: Option<i64>,
/// Texture handle to show image when created.
texture_handle: Option<TextureHandle>,
/// QR code view data state.
qr_image_state: Arc<RwLock<QrImageState>>,
} }
const DEFAULT_QR_SIZE: u32 = 512; const DEFAULT_QR_SIZE: u32 = 512;
impl QrCodeContent { impl QrCodeContent {
pub fn new(text: String, animated: bool) -> Self { pub fn new(text: String, animated: bool) -> Self {
Self { Self {
text, text,
animated, show_text: true,
animated_index: None, can_copy_text: true,
animation_time: None, max_size: DEFAULT_QR_SIZE as f32,
texture_handle: None, animated,
qr_image_state: Arc::new(RwLock::new(QrImageState::default())), animated_index: None,
} animation_time: None,
} texture_handle: None,
qr_image_state: Arc::new(RwLock::new(QrImageState::default())),
}
}
/// Draw QR code. /// Setup maximum QR code size.
pub fn ui(&mut self, ui: &mut egui::Ui, text: String, cb: &dyn PlatformCallbacks) { pub fn with_max_size(mut self, max_size: f32) -> Self {
if self.animated { self.max_size = max_size;
// Show animated QR code. self
self.animated_ui(ui, text, cb); }
} else {
// Show static QR code.
self.static_ui(ui, text, cb);
}
}
/// Draw animated QR code content. /// Hide text below QR code.
fn animated_ui(&mut self, ui: &mut egui::Ui, text: String, cb: &dyn PlatformCallbacks) { pub fn hide_text(mut self) -> Self {
if !self.has_image() { self.show_text = false;
let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0; self
ui.vertical_centered(|ui| { }
ui.add_space(space);
View::big_loading_spinner(ui);
ui.add_space(space);
});
// Create multiple vector images from text if not creating. /// Do not show button to copy QR code text.
if !self.loading() { pub fn no_copy(mut self) -> Self {
self.create_svg_list(text); self.can_copy_text = false;
} self
} else { }
let svg_list = {
let r_create = self.qr_image_state.read();
r_create.svg_list.clone().unwrap()
};
// Setup animated index. /// Draw QR code.
let now = chrono::Utc::now().timestamp_millis(); pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
if now - *self.animation_time.get_or_insert(now) > 100 { if self.animated {
if let Some(i) = self.animated_index { // Show animated QR code.
self.animated_index = Some(i + 1); self.animated_ui(ui, cb);
} } else {
if *self.animated_index.get_or_insert(0) == svg_list.len() { // Show static QR code.
self.animated_index = Some(0); self.static_ui(ui, cb);
} }
self.animation_time = Some(now); ui.add_space(8.0);
} View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
}
let svg = svg_list[self.animated_index.unwrap_or(0)].clone(); /// Draw animated QR code content.
fn animated_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
if !self.has_image() {
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);
});
// Create images from SVG data. // Create multiple vector images from text if not creating.
self.qr_image_ui(svg, ui); if !self.loading() {
self.create_svg_list();
}
} else {
let svg_list = {
let r_create = self.qr_image_state.read();
r_create.svg_list.clone().unwrap()
};
// Show QR code text. // Setup animated index.
ui.add_space(6.0); let now = chrono::Utc::now().timestamp_millis();
View::ellipsize_text(ui, text.clone(), 16.0, Colors::inactive_text()); if now - *self.animation_time.get_or_insert(now) > 100 {
ui.add_space(6.0); if let Some(i) = self.animated_index {
self.animated_index = Some(i + 1);
}
if *self.animated_index.get_or_insert(0) == svg_list.len() {
self.animated_index = Some(0);
}
self.animation_time = Some(now);
}
ui.vertical_centered(|ui| { let svg = svg_list[self.animated_index.unwrap_or(0)].clone();
let sharing = {
let r_state = self.qr_image_state.read();
r_state.exporting || r_state.gif_creating
};
if !sharing {
// Show button to share QR.
let share_text = format!("{} {}", IMAGES_SQUARE, t!("share"));
View::colored_text_button(ui,
share_text,
Colors::blue(),
Colors::white_or_black(false), || {
{
let mut w_state = self.qr_image_state.write();
w_state.exporting = true;
}
// Create GIF to export.
self.create_qr_gif(text, DEFAULT_QR_SIZE as usize);
});
} else {
ui.vertical_centered(|ui| {
ui.add_space(2.0);
View::small_loading_spinner(ui);
ui.add_space(1.0);
});
}
ui.add_space(8.0); // Create images from SVG data.
View::horizontal_line(ui, Colors::item_stroke()); self.qr_image_ui(svg, ui);
ui.add_space(8.0);
// Check if GIF was created to share. // Show QR code text.
let has_gif = { if self.show_text {
let r_state = self.qr_image_state.read(); self.text_ui(ui);
r_state.gif_data.is_some() }
};
if has_gif {
let data = {
let r_state = self.qr_image_state.read();
r_state.gif_data.clone().unwrap()
};
let name = format!("{}.gif", chrono::Utc::now().timestamp());
cb.share_data(name, data).unwrap_or_default();
// Clear GIF data and exporting flag.
{
let mut w_state = self.qr_image_state.write();
w_state.gif_data = None;
w_state.exporting = false;
}
}
});
ui.ctx().request_repaint(); ui.vertical_centered(|ui| {
} let sharing = {
} let r_state = self.qr_image_state.read();
r_state.exporting || r_state.gif_creating
};
if !sharing {
ui.vertical_centered(|ui| {
// Show button to share QR.
let share_text = format!("{} {}", IMAGES_SQUARE, t!("share"));
View::colored_text_button(
ui,
share_text,
Colors::blue(),
Colors::white_or_black(false),
|| {
{
let mut w_state = self.qr_image_state.write();
w_state.exporting = true;
}
// Create GIF to export.
self.create_qr_gif();
},
);
});
} else {
ui.vertical_centered(|ui| {
ui.add_space(2.0);
View::small_loading_spinner(ui);
ui.add_space(1.0);
});
}
/// Draw static QR code content. // Check if GIF was created to share.
fn static_ui(&mut self, ui: &mut egui::Ui, text: String, cb: &dyn PlatformCallbacks) { let has_gif = {
if !self.has_image() { let r_state = self.qr_image_state.read();
let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0; r_state.gif_data.is_some()
ui.vertical_centered(|ui| { };
ui.add_space(space); if has_gif {
View::big_loading_spinner(ui); let data = {
ui.add_space(space); let r_state = self.qr_image_state.read();
}); r_state.gif_data.clone().unwrap()
};
let name = format!("{}.gif", chrono::Utc::now().timestamp());
cb.share_data(name, data).unwrap_or_default();
// Clear GIF data and exporting flag.
{
let mut w_state = self.qr_image_state.write();
w_state.gif_data = None;
w_state.exporting = false;
}
}
});
// Create vector image from text if not creating. ui.ctx().request_repaint();
if !self.loading() { }
self.create_svg(text); }
}
} else {
// Create image from SVG data.
let svg = {
let r_state = self.qr_image_state.read();
r_state.svg.clone().unwrap()
};
self.qr_image_ui(svg, ui);
// Show QR code text. /// Draw static QR code content.
ui.add_space(6.0); fn static_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
View::ellipsize_text(ui, text.clone(), 16.0, Colors::inactive_text()); if !self.has_image() {
ui.add_space(6.0); let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0;
ui.vertical_centered(|ui| {
ui.add_space(space);
View::big_loading_spinner(ui);
ui.add_space(space);
});
// Show button to share QR. // Create vector image from text if not creating.
ui.vertical_centered(|ui| { if !self.loading() {
let share_text = format!("{} {}", IMAGES_SQUARE, t!("share")); self.create_svg();
View::colored_text_button(ui, }
share_text, } else {
Colors::blue(), // Create image from SVG data.
Colors::white_or_black(false), || { let svg = {
if let Ok(qr) = QrCode::encode_text(text.as_str(), qrcodegen::QrCodeEcc::Low) { let r_state = self.qr_image_state.read();
if let Some(data) = Self::qr_to_image_data(qr, DEFAULT_QR_SIZE as usize) { r_state.svg.clone().unwrap()
let mut png = vec![]; };
let png_enc = PngEncoder::new_with_quality(&mut png, self.qr_image_ui(svg, ui);
CompressionType::Best,
FilterType::NoFilter);
if let Ok(()) = png_enc.write_image(data.as_slice(),
DEFAULT_QR_SIZE,
DEFAULT_QR_SIZE,
ExtendedColorType::L8) {
let name = format!("{}.png", chrono::Utc::now().timestamp());
cb.share_data(name, png).unwrap_or_default();
}
}
}
});
});
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
}
}
/// Draw QR code image content. // Show QR code text.
fn qr_image_ui(&mut self, svg: Vec<u8>, ui: &mut egui::Ui) { if self.show_text {
let mut rect = ui.available_rect_before_wrap(); self.text_ui(ui);
rect.min += egui::emath::vec2(10.0, 0.0); } else {
rect.max -= egui::emath::vec2(10.0, 0.0); ui.add_space(8.0);
}
// Create background shape. if self.can_copy_text {
let mut bg_shape = RectShape { // Setup spacing between buttons.
rect, ui.spacing_mut().item_spacing = egui::Vec2::new(6.0, 0.0);
rounding: egui::Rounding::default(),
fill: egui::Color32::WHITE,
stroke: egui::Stroke::NONE,
blur_width: 0.0,
fill_texture_id: Default::default(),
uv: egui::Rect::ZERO
};
let bg_idx = ui.painter().add(bg_shape);
// Draw QR code image content. ui.columns(2, |columns| {
let mut content_rect = ui.allocate_ui_at_rect(rect, |ui| { columns[0].vertical_centered_justified(|ui| {
ui.add_space(10.0); // Draw copy button.
let size = SizeHint::Size(ui.available_width() as u32, ui.available_width() as u32); let copy_text = format!("{} {}", COPY, t!("copy"));
self.texture_handle = Some(View::svg_image(ui, "qr_code", svg.as_slice(), Some(size))); View::button(ui, copy_text, Colors::white_or_black(false), || {
ui.add_space(10.0); cb.copy_string_to_buffer(self.text.clone());
}).response.rect; });
});
columns[1].vertical_centered_justified(|ui| {
self.share_static_button_ui(ui, cb);
});
});
} else {
ui.vertical_centered(|ui| {
self.share_static_button_ui(ui, cb);
});
}
}
}
// Setup background shape to be painted behind content. /// Draw button to share static QR code.
content_rect.min -= egui::emath::vec2(10.0, 0.0); fn share_static_button_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
content_rect.max += egui::emath::vec2(10.0, 0.0); let share_text = format!("{} {}", IMAGES_SQUARE, t!("share"));
bg_shape.rect = content_rect; View::colored_text_button(
ui.painter().set(bg_idx, bg_shape); ui,
} share_text,
Colors::blue(),
Colors::white_or_black(false),
|| {
self.share_static(cb);
},
);
}
/// Check if QR code is loading. /// Share static QR code image.
fn loading(&self) -> bool { fn share_static(&self, cb: &dyn PlatformCallbacks) {
let r_state = self.qr_image_state.read(); let text = self.text.as_str();
r_state.loading if let Ok(qr) = QrCode::encode_text(text, qrcodegen::QrCodeEcc::Low) {
} let size = DEFAULT_QR_SIZE as usize;
if let Some(data) = Self::qr_to_image_data(qr, size) {
let mut png = vec![];
let png_enc = PngEncoder::new_with_quality(
&mut png,
CompressionType::Best,
FilterType::NoFilter,
);
if let Ok(()) = png_enc.write_image(
data.as_slice(),
DEFAULT_QR_SIZE,
DEFAULT_QR_SIZE,
ExtendedColorType::L8,
) {
let name = format!("{}.png", chrono::Utc::now().timestamp());
cb.share_data(name, png).unwrap_or_default();
}
}
}
}
/// Create multiple vector QR code images at separate thread. /// Draw QR code image content.
fn create_svg_list(&self, text: String) { fn qr_image_ui(&mut self, svg: Vec<u8>, ui: &mut egui::Ui) {
let qr_state = self.qr_image_state.clone(); View::max_width_ui(ui, self.max_size, |ui| {
thread::spawn(move || { let mut rect = ui.available_rect_before_wrap();
let mut encoder = ur::Encoder::bytes(text.as_bytes(), 100).unwrap(); rect.min += egui::emath::vec2(10.0, 0.0);
let mut data = Vec::with_capacity(encoder.fragment_count()); rect.max -= egui::emath::vec2(10.0, 0.0);
for _ in 0..encoder.fragment_count() {
let ur = encoder.next_part().unwrap();
if let Ok(qr) = QrCode::encode_text(ur.as_str(), qrcodegen::QrCodeEcc::Low) {
let svg = Self::qr_to_svg(qr, 0);
data.push(svg.into_bytes());
}
}
let mut w_state = qr_state.write();
if !data.is_empty() {
w_state.svg_list = Some(data);
}
w_state.loading = false;
});
}
/// Check if image was created. // Create background shape.
fn has_image(&self) -> bool { let mut bg_shape = RectShape::new(
let r_state = self.qr_image_state.read(); rect,
r_state.svg.is_some() || r_state.svg_list.is_some() egui::CornerRadius::default(),
} egui::Color32::WHITE,
egui::Stroke::NONE,
egui::StrokeKind::Outside,
);
let bg_idx = ui.painter().add(bg_shape.clone());
/// Create vector QR code image at separate thread. // Draw QR code image.
fn create_svg(&self, text: String) { let mut content_rect = ui
let qr_state = self.qr_image_state.clone(); .scope_builder(UiBuilder::new().max_rect(rect), |ui| {
thread::spawn(move || { ui.add_space(10.0);
if let Ok(qr) = QrCode::encode_text(text.as_str(), qrcodegen::QrCodeEcc::Low) { let size = SizeHint::Size {
let svg = Self::qr_to_svg(qr, 0); width: ui.available_width() as u32,
let mut w_state = qr_state.write(); height: ui.available_width() as u32,
w_state.loading = false; maintain_aspect_ratio: true,
w_state.svg = Some(svg.into_bytes()); };
} self.texture_handle = Some(View::svg_image(ui, "qr", svg.as_slice(), size));
}); ui.add_space(10.0);
} })
.response
.rect;
/// Convert QR code to SVG string. // Setup background size.
fn qr_to_svg(qr: QrCode, border: i32) -> String { content_rect.min -= egui::emath::vec2(10.0, 0.0);
let mut result = String::new(); content_rect.max += egui::emath::vec2(10.0, 0.0);
let dimension = qr.size().checked_add(border.checked_mul(2).unwrap()).unwrap(); bg_shape.rect = content_rect;
result += "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"; ui.painter().set(bg_idx, bg_shape);
result += "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n"; });
result += &format!( }
"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 {0} {0}\" stroke=\"none\">\n", dimension);
result += "\t<rect width=\"100%\" height=\"100%\" fill=\"#FFFFFF\"/>\n";
result += "\t<path d=\"";
for y in 0 .. qr.size() {
for x in 0 .. qr.size() {
if qr.get_module(x, y) {
if x != 0 || y != 0 {
result += " ";
}
result += &format!("M{},{}h1v1h-1z", x + border, y + border);
}
}
}
result += "\" fill=\"#000000\"/>\n";
result += "</svg>\n";
result
}
/// Create GIF image at separate thread. /// Draw QR code text.
fn create_qr_gif(&self, text: String, size: usize) { fn text_ui(&self, ui: &mut egui::Ui) {
{ ui.add_space(6.0);
let mut w_state = self.qr_image_state.write(); View::ellipsize_text(ui, self.text.clone(), 15.0, Colors::inactive_text());
w_state.gif_creating = true; ui.add_space(6.0);
}
} /// Check if QR code is loading.
let qr_state = self.qr_image_state.clone(); fn loading(&self) -> bool {
thread::spawn(move || { let r_state = self.qr_image_state.read();
// Setup GIF image encoder. r_state.loading
let mut gif = vec![]; }
{
// Generate QR codes from text.
let mut qrs = vec![];
let mut ur_enc = ur::Encoder::bytes(text.as_bytes(), 100).unwrap();
for _ in 0..ur_enc.fragment_count() {
let ur = ur_enc.next_part().unwrap();
if let Ok(qr) = qrcode::QrCode::with_error_correction_level(
ur.as_bytes(),
qrcode::EcLevel::L
) {
// Create an image from QR data.
let image = qr.render()
.max_dimensions(size as u32, size as u32)
.dark_color(image::Rgb([0, 0, 0]))
.light_color(image::Rgb([255, 255, 255]))
.build();
qrs.push(image);
}
}
if !qrs.is_empty() { /// Create multiple vector QR code images at separate thread.
// Generate GIF data. fn create_svg_list(&self) {
let color_map = &[0, 0, 0, 0xFF, 0xFF, 0xFF]; let qr_state = self.qr_image_state.clone();
let mut gif_enc = gif::Encoder::new(&mut gif, let text = self.text.clone();
qrs[0].width() as u16, thread::spawn(move || {
qrs[0].height() as u16, let mut encoder = ur::Encoder::bytes(text.as_bytes(), 64).unwrap();
color_map).unwrap(); let mut data = Vec::with_capacity(encoder.fragment_count());
gif_enc.set_repeat(gif::Repeat::Infinite).unwrap(); for _ in 0..encoder.fragment_count() {
for qr in qrs { let ur = encoder.next_part().unwrap();
let mut frame = gif::Frame::from_rgb(qr.width() as u16, if let Ok(qr) = QrCode::encode_text(ur.as_str(), qrcodegen::QrCodeEcc::Low) {
qr.height() as u16, let svg = Self::qr_to_svg(qr, 0);
qr.as_raw().as_slice()); data.push(svg.into_bytes());
frame.delay = 10; }
// Write an image to GIF encoder. }
if let Ok(_) = gif_enc.write_frame(&frame) { let mut w_state = qr_state.write();
continue; if !data.is_empty() {
} w_state.svg_list = Some(data);
// Exit on error. }
let mut w_state = qr_state.write(); w_state.loading = false;
w_state.gif_creating = false; });
return; }
}
}
}
// Setup GIF image data.
let mut w_state = qr_state.write();
if !gif.is_empty() {
w_state.gif_data = Some(gif);
}
w_state.gif_creating = false;
});
}
/// Convert QR code to image data. /// Check if image was created.
fn qr_to_image_data(qr: QrCode, size: usize) -> Option<Vec<u8>> { fn has_image(&self) -> bool {
if size >= 2usize.pow((size_of::<usize>() * 4) as u32) { let r_state = self.qr_image_state.read();
return None; r_state.svg.is_some() || r_state.svg_list.is_some()
} }
let margin_size = 1;
let s = qr.size();
let data_length = s as usize;
let data_length_with_margin = data_length + 2 * margin_size;
let point_size = size / data_length_with_margin;
if point_size == 0 {
return None;
}
let margin = (size - (point_size * data_length)) / 2;
let length = size * size;
let mut img_raw: Vec<u8> = vec![255u8; length];
for i in 0..s {
for j in 0..s {
if qr.get_module(i, j) {
let x = i as usize * point_size + margin;
let y = j as usize * point_size + margin;
for j in y..(y + point_size) { /// Create vector QR code image at separate thread.
let offset = j * size; fn create_svg(&self) {
for i in x..(x + point_size) { let qr_state = self.qr_image_state.clone();
img_raw[offset + i] = 0; let text = self.text.clone();
} thread::spawn(move || {
} if let Ok(qr) = QrCode::encode_text(text.as_str(), qrcodegen::QrCodeEcc::Low) {
} let svg = Self::qr_to_svg(qr, 0);
} let mut w_state = qr_state.write();
} w_state.loading = false;
Some(img_raw) w_state.svg = Some(svg.into_bytes());
} }
});
}
/// Reset QR code image content state to default. /// Convert QR code to SVG string.
pub fn clear_state(&mut self) { fn qr_to_svg(qr: QrCode, border: i32) -> String {
let mut w_create = self.qr_image_state.write(); let mut result = String::new();
*w_create = QrImageState::default(); let dimension = qr
} .size()
} .checked_add(border.checked_mul(2).unwrap())
.unwrap();
result += "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
result += "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n";
result += &format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 {0} {0}\" stroke=\"none\">\n",
dimension
);
result += "\t<rect width=\"100%\" height=\"100%\" fill=\"#FFFFFF\"/>\n";
result += "\t<path d=\"";
for y in 0..qr.size() {
for x in 0..qr.size() {
if qr.get_module(x, y) {
if x != 0 || y != 0 {
result += " ";
}
result += &format!("M{},{}h1v1h-1z", x + border, y + border);
}
}
}
result += "\" fill=\"#000000\"/>\n";
result += "</svg>\n";
result
}
/// Create GIF image at separate thread.
fn create_qr_gif(&self) {
{
let mut w_state = self.qr_image_state.write();
w_state.gif_creating = true;
}
let qr_state = self.qr_image_state.clone();
let text = self.text.clone();
thread::spawn(move || {
// Setup GIF image encoder.
let mut gif = vec![];
{
// Generate QR codes from text.
let mut qrs = vec![];
let mut ur_enc = ur::Encoder::bytes(text.as_bytes(), 100).unwrap();
for _ in 0..ur_enc.fragment_count() {
let ur = ur_enc.next_part().unwrap();
if let Ok(qr) = qrcode::QrCode::with_error_correction_level(
ur.as_bytes(),
qrcode::EcLevel::L,
) {
// Create an image from QR data.
let image = qr
.render()
.max_dimensions(DEFAULT_QR_SIZE, DEFAULT_QR_SIZE)
.dark_color(image::Rgb([0, 0, 0]))
.light_color(image::Rgb([255, 255, 255]))
.build();
qrs.push(image);
}
}
if !qrs.is_empty() {
// Generate GIF data.
let color_map = &[0, 0, 0, 0xFF, 0xFF, 0xFF];
let mut gif_enc = gif::Encoder::new(
&mut gif,
qrs[0].width() as u16,
qrs[0].height() as u16,
color_map,
)
.unwrap();
gif_enc.set_repeat(gif::Repeat::Infinite).unwrap();
for qr in qrs {
let mut frame = gif::Frame::from_rgb(
qr.width() as u16,
qr.height() as u16,
qr.as_raw().as_slice(),
);
frame.delay = 10;
// Write an image to GIF encoder.
if let Ok(_) = gif_enc.write_frame(&frame) {
continue;
}
// Exit on error.
let mut w_state = qr_state.write();
w_state.gif_creating = false;
return;
}
}
}
// Setup GIF image data.
let mut w_state = qr_state.write();
if !gif.is_empty() {
w_state.gif_data = Some(gif);
}
w_state.gif_creating = false;
});
}
/// Convert QR code to image data.
fn qr_to_image_data(qr: QrCode, size: usize) -> Option<Vec<u8>> {
if size >= 2usize.pow((size_of::<usize>() * 4) as u32) {
return None;
}
let margin_size = 1;
let s = qr.size();
let data_length = s as usize;
let data_length_with_margin = data_length + 2 * margin_size;
let point_size = size / data_length_with_margin;
if point_size == 0 {
return None;
}
let margin = (size - (point_size * data_length)) / 2;
let length = size * size;
let mut img_raw: Vec<u8> = vec![255u8; length];
for i in 0..s {
for j in 0..s {
if qr.get_module(i, j) {
let x = i as usize * point_size + margin;
let y = j as usize * point_size + margin;
for j in y..(y + point_size) {
let offset = j * size;
for i in x..(x + point_size) {
img_raw[offset + i] = 0;
}
}
}
}
}
Some(img_raw)
}
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2024 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::scroll_area::ScrollBarVisibility;
use egui::{Id, ScrollArea};
use crate::gui::Colors;
use crate::gui::icons::COPY;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::QrScanResult;
use crate::gui::views::{CameraContent, Modal, View};
/// QR code scanning content.
pub struct CameraScanContent {
/// Camera content.
camera_content: Option<CameraContent>,
/// Scan result.
qr_scan_result: Option<QrScanResult>,
}
impl Default for CameraScanContent {
fn default() -> Self {
Self {
camera_content: Some(CameraContent::default()),
qr_scan_result: None,
}
}
}
impl CameraScanContent {
/// Draw [`Modal`] content.
pub fn modal_ui(
&mut self,
ui: &mut egui::Ui,
cb: &dyn PlatformCallbacks,
mut on_result: impl FnMut(&QrScanResult),
) {
// Show scan result if exists or show camera content while scanning.
if let Some(result) = &self.qr_scan_result.clone() {
Self::result_ui(
ui,
result,
cb,
|| {
Modal::close();
},
|| {
self.qr_scan_result = None;
cb.start_camera();
Modal::set_title(t!("scan_qr"));
},
);
} else if let Some(camera_content) = self.camera_content.as_mut() {
if let Some(result) = camera_content.qr_scan_result() {
cb.stop_camera();
self.camera_content = None;
on_result(&result);
// Set result and rename modal title.
self.qr_scan_result = Some(result);
Modal::set_title(t!("scan_result"));
} else {
// Draw camera content.
ui.add_space(6.0);
self.camera_content.as_mut().unwrap().ui(ui, cb);
ui.add_space(12.0);
ui.vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
cb.stop_camera();
self.camera_content = None;
Modal::close();
},
);
});
}
}
ui.add_space(6.0);
}
/// Draw scan result content.
pub fn result_ui(
ui: &mut egui::Ui,
result: &QrScanResult,
cb: &dyn PlatformCallbacks,
on_close: impl FnOnce(),
on_repeat: impl FnOnce(),
) {
let mut result_text = result.text();
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(3.0);
ScrollArea::vertical()
.id_salt(Id::from("qr_scan_result_input"))
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.max_height(128.0)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(7.0);
egui::TextEdit::multiline(&mut result_text)
.font(egui::TextStyle::Small)
.desired_rows(5)
.interactive(false)
.desired_width(f32::INFINITY)
.show(ui);
ui.add_space(6.0);
});
ui.add_space(2.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(10.0);
// Show copy button.
ui.vertical_centered(|ui| {
let copy_text = format!("{} {}", COPY, t!("copy"));
View::button(ui, copy_text, Colors::white_or_black(false), || {
cb.copy_string_to_buffer(result_text.to_string());
});
});
ui.add_space(10.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.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!("close"), Colors::white_or_black(false), || {
on_close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("repeat"), Colors::white_or_black(false), || {
on_repeat();
});
});
});
}
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::AppConfig;
use crate::gui::Colors;
use crate::gui::icons::GLOBE_SIMPLE;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::View;
use crate::gui::views::settings::{InterfaceSettingsContent, NetworkSettingsContent};
use crate::gui::views::types::ContentContainer;
/// Application settings content.
pub struct SettingsContent {
/// User interface settings.
interface_settings: InterfaceSettingsContent,
/// Network communication settings.
network_settings: NetworkSettingsContent,
// tor_settings: TorSettingsContent,
}
impl Default for SettingsContent {
fn default() -> Self {
Self {
interface_settings: InterfaceSettingsContent::default(),
network_settings: NetworkSettingsContent::default(),
//tor_settings: TorSettingsContent::default(),
}
}
}
impl SettingsContent {
/// Draw application settings content.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
ui.add_space(5.0);
View::checkbox(ui, AppConfig::check_updates(), t!("check_updates"), || {
AppConfig::toggle_check_updates();
});
ui.add_space(6.0);
View::horizontal_line(ui, Colors::stroke());
// Show interface settings.
self.interface_settings.ui(ui, cb);
ui.add_space(8.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(6.0);
View::sub_title(ui, format!("{} {}", GLOBE_SIMPLE, t!("network.self")));
View::horizontal_line(ui, Colors::stroke());
ui.add_space(6.0);
// Show network settings.
self.network_settings.ui(ui, cb);
ui.add_space(8.0);
// Do not show Tor settings on Android.
// let os = OperatingSystem::from_target_os();
// let show_tor = os != OperatingSystem::Android;
// if show_tor {
// View::horizontal_line(ui, Colors::stroke());
// ui.add_space(6.0);
//
// View::sub_title(ui, format!("{} {}", CIRCLE_HALF, t!("transport.tor_network")));
// View::horizontal_line(ui, Colors::stroke());
// ui.add_space(6.0);
//
// // Show Tor settings.
// self.tor_settings.ui(ui, cb);
// ui.add_space(8.0);
// }
}
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use eframe::epaint::RectShape;
use egui::{Align, CursorIcon, Layout, RichText, Sense, StrokeKind, UiBuilder};
use crate::AppConfig;
use crate::gui::Colors;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::ContentContainer;
use crate::gui::views::{Modal, View};
/// User interface settings content.
pub struct InterfaceSettingsContent {
/// Current locale.
locale: String,
}
impl ContentContainer for InterfaceSettingsContent {
fn modal_ids(&self) -> Vec<&'static str> {
vec![]
}
fn modal_ui(&mut self, _: &mut egui::Ui, _: &Modal, _: &dyn PlatformCallbacks) {}
fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) {
ui.add_space(5.0);
// Draw theme selection.
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("theme")).size(16.0).color(Colors::gray()));
});
let saved_use_dark = AppConfig::dark_theme().unwrap_or(false);
let mut selected_use_dark = saved_use_dark;
ui.add_space(8.0);
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_use_dark, false, t!("light"));
});
columns[1].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_use_dark, true, t!("dark"));
})
});
ui.add_space(14.0);
if saved_use_dark != selected_use_dark {
AppConfig::set_dark_theme(selected_use_dark);
crate::setup_visuals(ui.ctx());
}
// Draw language selection.
let locales = rust_i18n::available_locales!();
for (index, locale) in locales.iter().enumerate() {
self.language_item_ui(locale, ui, index, locales.len());
}
ui.add_space(4.0);
}
}
impl Default for InterfaceSettingsContent {
fn default() -> Self {
let locale = if let Some(lang) = AppConfig::locale() {
lang
} else {
rust_i18n::locale().to_string()
};
Self { locale }
}
}
impl InterfaceSettingsContent {
/// Draw language selection item content.
fn language_item_ui(&mut self, locale: &str, ui: &mut egui::Ui, index: usize, len: usize) {
let is_current = self.locale == locale;
// Draw round background.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(50.0);
let r = View::item_rounding(index, len, false);
let bg = if is_current {
Colors::fill()
} else {
Colors::fill_lite()
};
let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside);
let bg_idx = ui.painter().add(bg_shape.clone());
let res = ui
.scope_builder(
UiBuilder::new()
.sense(Sense::click())
.layout(Layout::right_to_left(Align::Center))
.max_rect(rect),
|ui| {
if is_current {
View::selected_item_check(ui);
}
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(
layout_size,
Layout::left_to_right(Align::Center),
|ui| {
ui.add_space(12.0);
ui.vertical(|ui| {
// Draw language name.
ui.add_space(12.0);
let color = if is_current {
Colors::title(false)
} else {
Colors::gray()
};
ui.label(
RichText::new(t!("lang_name", locale = locale))
.size(17.0)
.color(color),
);
ui.add_space(14.0);
});
},
);
},
)
.response;
let clicked = res.clicked() || res.long_touched();
// Setup background and cursor.
if res.hovered() && !is_current {
res.on_hover_cursor(CursorIcon::PointingHand);
bg_shape.fill = Colors::fill();
}
ui.painter().set(bg_idx, bg_shape);
// Handle clicks on layout.
if clicked && !is_current {
rust_i18n::set_locale(locale);
AppConfig::save_locale(locale);
self.locale = locale.to_string();
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
mod content;
pub use content::*;
mod interface;
pub use interface::*;
mod network;
pub use network::*;
mod tor;
pub use tor::*;
+311
View File
@@ -0,0 +1,311 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use eframe::epaint::RectShape;
use egui::{Align, CursorIcon, Id, Layout, RichText, Sense, StrokeKind, UiBuilder};
use url::Url;
use crate::AppConfig;
use crate::gui::Colors;
use crate::gui::icons::{CLOUD_CHECK, CLOUD_SLASH};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::{Modal, TextEdit, View};
/// Network communication settings content.
pub struct NetworkSettingsContent {
/// Proxy URL input value for [`Modal`].
proxy_url_edit: String,
/// Flag to check if entered proxy address was correct.
proxy_url_error: bool,
}
/// Identifier for proxy URL edit [`Modal`].
const PROXY_URL_EDIT_MODAL: &'static str = "settings_proxy_edit_modal";
impl ContentContainer for NetworkSettingsContent {
fn modal_ids(&self) -> Vec<&'static str> {
vec![PROXY_URL_EDIT_MODAL]
}
fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
match modal.id {
PROXY_URL_EDIT_MODAL => self.proxy_modal_ui(ui, cb),
_ => {}
}
}
fn container_ui(&mut self, ui: &mut egui::Ui, _: &dyn PlatformCallbacks) {
let use_proxy = AppConfig::use_proxy();
View::checkbox(ui, use_proxy, t!("app_settings.proxy"), || {
// Show edit modal when both URLs are empty.
if AppConfig::http_proxy_url().is_none()
&& AppConfig::socks_proxy_url().is_none()
&& !use_proxy
{
Modal::new(PROXY_URL_EDIT_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("app_settings.proxy"))
.show();
} else {
AppConfig::toggle_use_proxy();
}
});
if !use_proxy {
ui.add_space(4.0);
ui.label(
RichText::new(t!("app_settings.proxy_desc"))
.size(16.0)
.color(Colors::inactive_text()),
);
ui.add_space(8.0);
} else {
ui.add_space(8.0);
// Draw proxy type selection.
Self::proxy_type_ui(ui);
// Draw proxy URL info.
self.proxy_item_ui(ui);
ui.add_space(6.0);
}
}
}
impl Default for NetworkSettingsContent {
fn default() -> Self {
Self {
proxy_url_edit: "".to_string(),
proxy_url_error: false,
}
}
}
impl NetworkSettingsContent {
/// Draw proxy edit modal content.
fn proxy_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let on_save = |c: &mut NetworkSettingsContent| {
let proxy = c.proxy_url_edit.trim().to_string();
let use_socks = AppConfig::use_socks_proxy();
// Clear value if empty.
if proxy.is_empty() {
if use_socks {
AppConfig::save_socks_proxy_url(None);
} else {
AppConfig::save_http_proxy_url(None);
}
Modal::close();
return;
}
// Format URL.
let http = "http://";
let socks = "socks5://";
let url = if use_socks {
let p = proxy.replace(http, "");
if !p.contains(socks) {
format!("{}{}", socks, p)
} else {
p
}
} else {
let p = proxy.replace(socks, "");
if !p.contains(http) {
format!("{}{}", http, p)
} else {
p
}
};
c.proxy_url_error = Url::parse(url.as_str()).is_err();
if !c.proxy_url_error {
// Save result when no error.
if !AppConfig::use_proxy() {
AppConfig::toggle_use_proxy();
}
if use_socks {
AppConfig::save_socks_proxy_url(Some(url))
} else {
AppConfig::save_http_proxy_url(Some(url));
}
Modal::close();
}
};
ui.add_space(6.0);
ui.vertical_centered(|ui| {
let label = format!("{}:", t!("enter_url"));
ui.label(RichText::new(label).size(17.0).color(Colors::gray()));
ui.add_space(8.0);
// Draw proxy URL text edit.
let mut edit =
TextEdit::new(Id::from("proxy_url_edit").with(PROXY_URL_EDIT_MODAL).with(
if AppConfig::use_proxy() {
"socks5"
} else {
"http"
},
))
.paste();
edit.ui(ui, &mut self.proxy_url_edit, cb);
if edit.enter_pressed {
on_save(self);
}
// Show error when specified address is incorrect.
if self.proxy_url_error {
ui.add_space(10.0);
ui.label(
RichText::new(t!("wallets.invalid_url"))
.size(16.0)
.color(Colors::red()),
);
}
ui.add_space(12.0);
// Show type selection when both URLs are empty.
if AppConfig::socks_proxy_url().is_none() && AppConfig::http_proxy_url().is_none() {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
Self::proxy_type_ui(ui);
});
ui.add_space(4.0);
}
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
});
}
/// Draw proxy item content.
fn proxy_item_ui(&mut self, ui: &mut egui::Ui) {
// Draw round background.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(56.0);
let r = View::item_rounding(0, 1, false);
let bg = Colors::fill_lite();
let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside);
let bg_idx = ui.painter().add(bg_shape.clone());
let res = ui
.scope_builder(
UiBuilder::new()
.sense(Sense::click())
.layout(Layout::right_to_left(Align::Center))
.max_rect(rect),
|ui| {
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(
layout_size,
Layout::left_to_right(Align::Center),
|ui| {
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(4.0);
let use_socks = AppConfig::use_socks_proxy();
let proxy_url = if use_socks {
AppConfig::socks_proxy_url()
} else {
AppConfig::http_proxy_url()
};
let (url, color, icon, text) = if let Some(url) = proxy_url {
(
url,
Colors::title(false),
CLOUD_CHECK,
t!("network_settings.enabled"),
)
} else {
(
t!("enter_url").into(),
Colors::inactive_text(),
CLOUD_SLASH,
t!("network_settings.disabled"),
)
};
View::ellipsize_text(ui, url, 18.0, color);
ui.add_space(1.0);
let value = format!("{} {}", icon, text);
ui.label(RichText::new(value).size(15.0).color(Colors::gray()));
ui.add_space(3.0);
});
},
);
},
)
.response;
let clicked = res.clicked() || res.long_touched();
// Setup background and cursor.
if res.hovered() {
res.on_hover_cursor(CursorIcon::PointingHand);
bg_shape.fill = Colors::fill();
}
ui.painter().set(bg_idx, bg_shape);
// Handle clicks on layout.
if clicked {
let url = if AppConfig::use_socks_proxy() {
AppConfig::socks_proxy_url().unwrap_or("".to_string())
} else {
AppConfig::http_proxy_url().unwrap_or("".to_string())
};
self.proxy_url_edit = url;
// Show proxy URL edit modal.
Modal::new(PROXY_URL_EDIT_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("app_settings.proxy"))
.show();
}
}
/// Draw proxy type selection.
fn proxy_type_ui(ui: &mut egui::Ui) {
// Draw proxy type selection.
let saved_use_socks = AppConfig::use_socks_proxy();
let mut selected_use_socks = saved_use_socks;
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_use_socks, true, "SOCKS5".to_string());
});
columns[1].vertical_centered(|ui| {
View::radio_value(ui, &mut selected_use_socks, false, "HTTP".to_string());
})
});
ui.add_space(14.0);
if saved_use_socks != selected_use_socks {
AppConfig::toggle_use_socks_proxy();
}
}
}
+678
View File
@@ -0,0 +1,678 @@
// Copyright 2025 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use eframe::epaint::RectShape;
use egui::scroll_area::ScrollBarVisibility;
use egui::{Align, CursorIcon, Id, Layout, RichText, ScrollArea, Sense, StrokeKind, UiBuilder};
use std::fs;
use url::Url;
use crate::gui::Colors;
use crate::gui::icons::{CLIPBOARD_TEXT, CLOUD_CHECK, NOTCHES, PENCIL, SCAN, TERMINAL};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::{
CameraScanContent, FilePickContent, FilePickContentType, Modal, TextEdit, View,
};
use crate::tor::{TorBridge, TorConfig, TorProxy};
/// Transport settings content.
pub struct TorSettingsContent {
/// Flag to check if settings were changed.
pub settings_changed: bool,
/// Proxy URL input value for [`Modal`].
proxy_url_edit: String,
/// Flag to check if entered proxy address was correct.
proxy_url_error: bool,
/// Tor bridge binary path value for [`Modal`].
bridge_bin_path_edit: String,
/// Button to pick binary file for bridge.
bridge_bin_pick_file: FilePickContent,
/// Tor bridge connection line value for [`Modal`].
bridge_conn_line_edit: String,
/// Bridge line QR code scanner [`Modal`] content.
bridge_qr_scan_content: Option<CameraScanContent>,
}
/// Identifier for proxy URL edit [`Modal`].
const PROXY_URL_EDIT_MODAL: &'static str = "tor_proxy_edit_modal";
/// Identifier for bridge binary path input [`Modal`].
const BRIDGE_BIN_EDIT_MODAL: &'static str = "bridge_bin_edit_modal";
/// Identifier for bridge connection line input [`Modal`].
const BRIDGE_CONN_LINE_EDIT_MODAL: &'static str = "bridge_conn_line_edit_modal";
/// Identifier for [`Modal`] to scan bridge line from QR code.
const SCAN_BRIDGE_CONN_LINE_MODAL: &'static str = "scan_bridge_conn_line_modal";
impl Default for TorSettingsContent {
fn default() -> Self {
// Setup Tor bridge binary path edit text.
let bridge = TorConfig::get_bridge();
let (bin_path, conn_line) = if let Some(b) = bridge {
(b.binary_path(), b.connection_line())
} else {
("".to_string(), "".to_string())
};
Self {
settings_changed: false,
proxy_url_edit: "".to_string(),
proxy_url_error: false,
bridge_bin_path_edit: bin_path,
bridge_bin_pick_file: FilePickContent::new(FilePickContentType::ItemButton(
View::item_rounding(0, 1, true),
))
.no_parse(),
bridge_conn_line_edit: conn_line,
bridge_qr_scan_content: None,
}
}
}
impl ContentContainer for TorSettingsContent {
fn modal_ids(&self) -> Vec<&'static str> {
vec![
PROXY_URL_EDIT_MODAL,
BRIDGE_BIN_EDIT_MODAL,
BRIDGE_CONN_LINE_EDIT_MODAL,
SCAN_BRIDGE_CONN_LINE_MODAL,
]
}
fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
match modal.id {
PROXY_URL_EDIT_MODAL => self.proxy_modal_ui(ui, cb),
BRIDGE_BIN_EDIT_MODAL => self.bridge_bin_edit_modal_ui(ui, cb),
BRIDGE_CONN_LINE_EDIT_MODAL => self.bridge_conn_line_edit_modal_ui(ui, cb),
SCAN_BRIDGE_CONN_LINE_MODAL => {
if let Some(content) = self.bridge_qr_scan_content.as_mut() {
let mut close = false;
content.modal_ui(ui, cb, |res| {
// Save connection line after scanning.
let line = res.text();
let bridge = TorConfig::get_bridge().unwrap();
if bridge.connection_line() != line {
TorBridge::save_bridge_conn_line(&bridge, line);
self.settings_changed = true;
}
close = true;
});
if close {
self.bridge_qr_scan_content = None;
cb.stop_camera();
Modal::close();
}
}
}
_ => {}
}
}
fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
ui.label(
RichText::new(format!("{}:", t!("wallets.conn_method")))
.size(17.0)
.color(Colors::inactive_text()),
);
ui.add_space(10.0);
let mut proxy = TorConfig::get_proxy();
let current_proxy = proxy.clone();
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
let name = t!("network_settings.default");
View::radio_value(ui, &mut proxy, None, name);
});
columns[1].vertical_centered(|ui| {
let name = t!("app_settings.proxy");
let val = current_proxy
.clone()
.unwrap_or(TorProxy::SOCKS5(TorProxy::DEFAULT_SOCKS5_URL.to_string()));
View::radio_value(ui, &mut proxy, Some(val), name);
});
});
ui.add_space(14.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(6.0);
if let Some(p) = proxy.as_mut() {
ui.label(
RichText::new(format!("{}:", t!("app_settings.proxy")))
.size(17.0)
.color(Colors::inactive_text()),
);
ui.add_space(10.0);
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
let value = TorConfig::get_socks5_proxy();
View::radio_value(ui, p, value, "SOCKS5".to_string());
});
columns[1].vertical_centered(|ui| {
let value = TorConfig::get_http_proxy();
View::radio_value(ui, p, value, "HTTP".to_string());
});
});
ui.add_space(14.0);
// Show proxy settings.
self.proxy_item_ui(p.url(), ui);
ui.add_space(8.0);
}
// Check if proxy type was changed to save.
if current_proxy != proxy {
TorConfig::save_proxy(proxy.clone());
self.settings_changed = true;
}
if proxy.is_some() {
return;
}
let bridge = TorConfig::get_bridge();
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("transport.bridges_desc"))
.size(17.0)
.color(Colors::inactive_text()),
);
// Draw checkbox to enable/disable bridges.
View::checkbox(ui, bridge.is_some(), t!("transport.bridges"), || {
let value = if bridge.is_some() {
None
} else {
let default_bridge = TorConfig::get_webtunnel();
self.bridge_bin_path_edit = default_bridge.binary_path();
self.bridge_conn_line_edit = default_bridge.connection_line();
Some(default_bridge)
};
TorConfig::save_bridge(value);
self.settings_changed = true;
});
});
if bridge.is_some() {
ui.add_space(6.0);
// Show bridge selection for desktop.
if View::is_desktop() {
let current_bridge = bridge.unwrap();
let mut bridge = current_bridge.clone();
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
// Show Webtunnel bridge selector.
let webtunnel = TorConfig::get_webtunnel();
let name = webtunnel.protocol_name().to_uppercase();
View::radio_value(ui, &mut bridge, webtunnel, name);
});
columns[1].vertical_centered(|ui| {
// Show Obfs4 bridge selector.
let obfs4 = TorConfig::get_obfs4();
let name = obfs4.protocol_name().to_uppercase();
View::radio_value(ui, &mut bridge, obfs4, name);
});
});
ui.add_space(10.0);
ui.vertical_centered(|ui| {
// Show Snowflake bridge selector.
let snowflake = TorConfig::get_snowflake();
let name = snowflake.protocol_name().to_uppercase();
View::radio_value(ui, &mut bridge, snowflake, name);
});
ui.add_space(16.0);
// Check if bridge type was changed to save.
if current_bridge != bridge {
TorConfig::save_bridge(Some(bridge.clone()));
self.bridge_bin_path_edit = bridge.binary_path();
self.bridge_conn_line_edit = bridge.connection_line();
self.settings_changed = true;
}
}
if let Some(br) = TorConfig::get_bridge().as_ref() {
// Show bridge connection line setup.
self.conn_line_ui(ui, br, cb);
// Show bridge binary setup for desktop.
if View::is_desktop() {
self.bridge_bin_ui(ui, br, cb);
}
}
ui.add_space(8.0);
}
}
}
impl TorSettingsContent {
/// Draw proxy edit modal content.
fn proxy_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let on_save = |c: &mut TorSettingsContent| {
let http = "http://";
let socks = "socks5://";
let url = c.proxy_url_edit.trim().to_string();
c.proxy_url_error = Url::parse(url.as_str()).is_err();
if !c.proxy_url_error {
let proxy = TorConfig::get_proxy().unwrap();
if url.contains(socks) {
TorConfig::save_proxy(Some(TorProxy::SOCKS5(url)));
} else if url.contains(http) {
TorConfig::save_proxy(Some(TorProxy::HTTP(url)));
} else {
match proxy {
TorProxy::SOCKS5(_) => {
TorConfig::save_proxy(Some(TorProxy::SOCKS5(url)));
}
TorProxy::HTTP(_) => {
TorConfig::save_proxy(Some(TorProxy::HTTP(url)));
}
}
}
Modal::close();
}
};
ui.add_space(6.0);
ui.vertical_centered(|ui| {
let label = format!("{}:", t!("enter_url"));
ui.label(RichText::new(label).size(17.0).color(Colors::gray()));
ui.add_space(8.0);
// Draw proxy URL text edit.
let mut edit =
TextEdit::new(Id::from("proxy_url_edit").with(PROXY_URL_EDIT_MODAL)).paste();
edit.ui(ui, &mut self.proxy_url_edit, cb);
if edit.enter_pressed {
on_save(self);
}
// Show error when specified address is incorrect.
if self.proxy_url_error {
ui.add_space(10.0);
ui.label(
RichText::new(t!("wallets.invalid_url"))
.size(16.0)
.color(Colors::red()),
);
}
ui.add_space(12.0);
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
});
}
/// Draw proxy item content.
fn proxy_item_ui(&mut self, url: String, ui: &mut egui::Ui) {
// Setup layout size.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(56.0);
// Draw round background.
let bg_rect = rect.clone();
let item_rounding = View::item_rounding(0, 1, false);
ui.painter().rect(
bg_rect,
item_rounding,
Colors::fill(),
View::item_stroke(),
StrokeKind::Outside,
);
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| {
View::item_button(ui, View::item_rounding(0, 1, true), PENCIL, None, || {
self.proxy_url_edit = url.clone();
// Show proxy URL edit modal.
Modal::new(PROXY_URL_EDIT_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("app_settings.proxy"))
.show();
});
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(layout_size, Layout::left_to_right(Align::Center), |ui| {
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(4.0);
View::ellipsize_text(ui, url, 18.0, Colors::title(false));
ui.add_space(1.0);
let value = format!("{} {}", CLOUD_CHECK, t!("network_settings.enabled"));
ui.label(RichText::new(value).size(15.0).color(Colors::gray()));
ui.add_space(3.0);
});
});
});
}
/// Draw bridge connection line setup content.
fn conn_line_ui(&mut self, ui: &mut egui::Ui, bridge: &TorBridge, cb: &dyn PlatformCallbacks) {
// Draw round background.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(56.0);
let r = if View::is_desktop() {
View::item_rounding(0, 2, false)
} else {
View::item_rounding(0, 1, false)
};
let bg = Colors::fill_lite();
let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside);
let bg_idx = ui.painter().add(bg_shape.clone());
let res = ui
.scope_builder(
UiBuilder::new()
.sense(Sense::click())
.layout(Layout::right_to_left(Align::Center))
.max_rect(rect),
|ui| {
View::item_button(ui, View::item_rounding(0, 1, true), SCAN, None, || {
self.show_qr_scan_bridge_modal(cb);
});
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(
layout_size,
Layout::left_to_right(Align::Center),
|ui| {
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(4.0);
let line_text = bridge.connection_line();
View::ellipsize_text(ui, line_text, 18.0, Colors::title(false));
ui.add_space(1.0);
let line_desc = t!("transport.conn_line").replace(":", "");
let value = format!("{} {}", NOTCHES, line_desc);
ui.label(RichText::new(value).size(15.0).color(Colors::gray()));
ui.add_space(3.0);
});
},
);
},
)
.response;
let clicked = res.clicked() || res.long_touched();
// Setup background and cursor.
if res.hovered() {
res.on_hover_cursor(CursorIcon::PointingHand);
bg_shape.fill = Colors::fill();
}
ui.painter().set(bg_idx, bg_shape);
// Handle clicks on layout.
if clicked {
self.bridge_conn_line_edit = bridge.connection_line();
// Show connection line edit modal.
let title = bridge.protocol_name();
Modal::new(BRIDGE_CONN_LINE_EDIT_MODAL)
.position(ModalPosition::Center)
.title(title)
.show();
}
}
/// Show bridge connection line QR code scanner.
fn show_qr_scan_bridge_modal(&mut self, cb: &dyn PlatformCallbacks) {
self.bridge_qr_scan_content = Some(CameraScanContent::default());
// Show QR code scan modal.
Modal::new(SCAN_BRIDGE_CONN_LINE_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("scan_qr"))
.closeable(false)
.show();
cb.start_camera();
}
/// Draw bridge connection line input [`Modal`] content.
fn bridge_conn_line_edit_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let on_save = |c: &mut TorSettingsContent| {
let bridge = TorConfig::get_bridge().unwrap();
if bridge.connection_line() != c.bridge_conn_line_edit {
TorBridge::save_bridge_conn_line(&bridge, c.bridge_conn_line_edit.clone());
c.settings_changed = true;
}
Modal::close();
};
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("transport.conn_line"))
.size(17.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
// Draw connection line text edit.
ui.vertical_centered(|ui| {
let scroll_id = Id::from(BRIDGE_CONN_LINE_EDIT_MODAL);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(3.0);
ScrollArea::both()
.id_salt(scroll_id)
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.max_height(128.0)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(7.0);
let input_id = scroll_id.with("_input");
egui::TextEdit::multiline(&mut self.bridge_conn_line_edit)
.id(input_id)
.font(egui::TextStyle::Body)
.desired_rows(5)
.interactive(false)
.desired_width(f32::INFINITY)
.show(ui);
ui.add_space(6.0);
});
});
ui.add_space(2.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
// Draw paste button.
let paste_text = format!("{} {}", CLIPBOARD_TEXT, t!("paste"));
View::button(ui, paste_text, Colors::white_or_black(false), || {
self.bridge_conn_line_edit = cb.get_string_from_buffer();
});
});
columns[1].vertical_centered_justified(|ui| {
// Draw button to scan bridge QR code.
let scan_text = format!("{} {}", SCAN, t!("scan"));
View::button(ui, scan_text, Colors::white_or_black(false), || {
self.show_qr_scan_bridge_modal(cb);
});
});
});
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
// Close modal.
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
});
}
/// Draw bridge binary setup content.
fn bridge_bin_ui(&mut self, ui: &mut egui::Ui, bridge: &TorBridge, cb: &dyn PlatformCallbacks) {
// Draw round background.
let mut rect = ui.available_rect_before_wrap();
rect.set_height(56.0);
let r = View::item_rounding(1, 2, false);
let bg = Colors::fill_lite();
let mut bg_shape = RectShape::new(rect, r, bg, View::item_stroke(), StrokeKind::Outside);
let bg_idx = ui.painter().add(bg_shape.clone());
let res = ui
.scope_builder(
UiBuilder::new()
.sense(Sense::click())
.layout(Layout::right_to_left(Align::Center))
.max_rect(rect),
|ui| {
self.bridge_bin_pick_file.ui(ui, cb, |path| {
if bridge.binary_path() != path {
TorBridge::save_bridge_bin_path(bridge, path);
self.settings_changed = true;
}
});
let layout_size = ui.available_size();
ui.allocate_ui_with_layout(
layout_size,
Layout::left_to_right(Align::Center),
|ui| {
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(4.0);
let bin_text = bridge.binary_path();
View::ellipsize_text(ui, bin_text, 18.0, Colors::title(false));
ui.add_space(1.0);
let bin_desc = t!("transport.bin_file").replace(":", "");
let value = format!("{} {}", TERMINAL, bin_desc);
ui.label(RichText::new(value).size(15.0).color(Colors::gray()));
ui.add_space(3.0);
});
},
);
},
)
.response;
let clicked = res.clicked() || res.long_touched();
// Setup background and cursor.
if res.hovered() {
res.on_hover_cursor(CursorIcon::PointingHand);
bg_shape.fill = Colors::fill();
}
ui.painter().set(bg_idx, bg_shape);
// Handle clicks on layout.
if clicked {
self.bridge_bin_path_edit = bridge.binary_path();
// Show binary path edit modal.
let title = bridge.protocol_name();
Modal::new(BRIDGE_BIN_EDIT_MODAL)
.position(ModalPosition::CenterTop)
.title(title)
.show();
}
}
/// Draw bridge binary input [`Modal`] content.
fn bridge_bin_edit_modal_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let on_save = |c: &mut TorSettingsContent| {
let bridge = TorConfig::get_bridge().unwrap();
let exists = fs::exists(&c.bridge_bin_path_edit).unwrap_or_default();
if !exists {
return;
}
if bridge.binary_path() != c.bridge_bin_path_edit {
TorBridge::save_bridge_bin_path(&bridge, c.bridge_bin_path_edit.clone());
c.settings_changed = true;
}
Modal::close();
};
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("transport.bin_file"))
.size(17.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
// Draw bridge text edit.
let mut edit = TextEdit::new(Id::from(BRIDGE_BIN_EDIT_MODAL)).paste();
edit.ui(ui, &mut self.bridge_bin_path_edit, cb);
if edit.enter_pressed {
on_save(self);
}
ui.add_space(12.0);
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
// Close modal.
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
});
}
}
+94 -101
View File
@@ -12,118 +12,111 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use egui::{Margin, Id, Layout, Align}; use egui::{Align, Id, Layout, Margin, UiBuilder};
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::views::{Content, View};
use crate::gui::views::types::{TitleContentType, TitleType}; use crate::gui::views::types::{TitleContentType, TitleType};
use crate::gui::views::{Content, View};
/// Title panel with left/right action buttons and text in the middle. /// Title panel with left/right action buttons and text in the middle.
pub struct TitlePanel { pub struct TitlePanel {
/// Widget identifier. /// Widget identifier.
id: Id id: Id,
} }
impl TitlePanel { impl TitlePanel {
/// Default [`TitlePanel`] content height. /// Content height.
pub const DEFAULT_HEIGHT: f32 = 54.0; pub const HEIGHT: f32 = 54.0;
/// Create new title panel with provided identifier. /// Create new title panel with provided identifier.
pub fn new(id: Id) -> Self { pub fn new(id: Id) -> Self {
Self { Self { id }
id, }
}
}
pub fn ui(&self, pub fn ui(
title: TitleType, &self,
mut left_content: impl FnMut(&mut egui::Ui), title: TitleType,
mut right_content: impl FnMut(&mut egui::Ui), mut left_content: impl FnMut(&mut egui::Ui),
ui: &mut egui::Ui) { mut right_content: impl FnMut(&mut egui::Ui),
// Draw title panel. ui: &mut egui::Ui,
egui::TopBottomPanel::top(self.id) ) {
.resizable(false) // Draw title panel.
.exact_height(Self::DEFAULT_HEIGHT + View::get_top_inset()) egui::TopBottomPanel::top(self.id)
.frame(egui::Frame { .resizable(false)
inner_margin: Margin { .exact_height(Self::HEIGHT + View::get_top_inset())
left: View::far_left_inset_margin(ui), .frame(egui::Frame {
right: View::far_right_inset_margin(ui), inner_margin: Margin {
top: View::get_top_inset(), left: View::far_left_inset_margin(ui) as i8,
bottom: 0.0, right: View::far_right_inset_margin(ui) as i8,
}, top: View::get_top_inset() as i8,
fill: Colors::yellow(), bottom: 0.0 as i8,
..Default::default() },
}) ..Default::default()
.show_inside(ui, |ui| { })
let rect = ui.available_rect_before_wrap(); .show_inside(ui, |ui| {
ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Max), |ui| { let rect = ui.available_rect_before_wrap();
ui.horizontal_centered(|ui| { ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Max), |ui| {
(right_content)(ui); ui.horizontal_centered(|ui| {
}); (right_content)(ui);
ui.with_layout(Layout::left_to_right(Align::Min), |ui| { });
ui.horizontal_centered(|ui| { ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
(left_content)(ui); ui.horizontal_centered(|ui| {
}); (left_content)(ui);
}); });
match title { });
TitleType::Single(content) => { match title {
let content_rect = { TitleType::Single(content) => {
let mut r = rect; let content_rect = {
r.min.x += Self::DEFAULT_HEIGHT; let mut r = rect.clone();
r.max.x -= Self::DEFAULT_HEIGHT; r.min.x += Self::HEIGHT;
r r.max.x -= Self::HEIGHT;
}; r
ui.allocate_ui_at_rect(content_rect, |ui| { };
Self::title_text_content(ui, content); ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| {
}); Self::title_text_content(ui, content);
} });
TitleType::Dual(first, second) => { }
let first_rect = { TitleType::Dual(first, second) => {
let mut r = rect; let first_rect = {
r.max.x = r.min.x + Content::SIDE_PANEL_WIDTH - Self::DEFAULT_HEIGHT; let mut r = rect.clone();
r.min.x += Self::DEFAULT_HEIGHT; r.max.x = r.min.x + Content::SIDE_PANEL_WIDTH - Self::HEIGHT;
r r.min.x += Self::HEIGHT;
}; r
// Draw first title content. };
ui.allocate_ui_at_rect(first_rect, |ui| { // Draw first title content.
Self::title_text_content(ui, first); ui.scope_builder(UiBuilder::new().max_rect(first_rect), |ui| {
}); Self::title_text_content(ui, first);
});
let second_rect = { let second_rect = {
let mut r = rect; let mut r = rect.clone();
r.min.x = first_rect.max.x + 2.0 * Self::DEFAULT_HEIGHT; r.min.x = first_rect.max.x + 2.0 * Self::HEIGHT;
r.max.x -= Self::DEFAULT_HEIGHT; r.max.x -= Self::HEIGHT;
r r
}; };
// Draw second title content. // Draw second title content.
ui.allocate_ui_at_rect(second_rect, |ui| { ui.scope_builder(UiBuilder::new().max_rect(second_rect), |ui| {
Self::title_text_content(ui, second); Self::title_text_content(ui, second);
}); });
} }
} }
}); });
}); });
} }
/// Setup title text content. /// Setup title text content.
fn title_text_content(ui: &mut egui::Ui, content: TitleContentType) { fn title_text_content(ui: &mut egui::Ui, content: TitleContentType) {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| match content {
match content { TitleContentType::Title(text) => {
TitleContentType::Title(text) => { ui.add_space(13.0 + if !View::is_desktop() { 1.0 } else { 0.0 });
ui.add_space(13.0 + if !View::is_desktop() { View::ellipsize_text(ui, text.to_uppercase(), 19.0, Colors::title(true));
1.0 }
} else { TitleContentType::WithSubTitle(text, subtitle, animate) => {
0.0 ui.add_space(4.0);
}); View::ellipsize_text(ui, text.to_uppercase(), 18.0, Colors::title(true));
View::ellipsize_text(ui, text, 19.0, Colors::title(true)); ui.add_space(-2.0);
} View::animate_text(ui, subtitle, 15.0, Colors::text(true), animate)
TitleContentType::WithSubTitle(text, subtitle, animate) => { }
ui.add_space(4.0); });
View::ellipsize_text(ui, text, 18.0, Colors::title(true)); }
ui.add_space(-2.0);
View::animate_text(ui, subtitle, 15.0, Colors::text(true), animate)
}
}
});
}
} }
+96 -163
View File
@@ -12,211 +12,144 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use grin_util::ZeroingString;
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::Modal; use crate::gui::views::Modal;
use grin_util::ZeroingString;
/// Title type, can be single or dual title in the row. /// Title type, can be single or dual title in the row.
pub enum TitleType { pub enum TitleType {
/// Single title content. /// Single title content.
Single(TitleContentType), Single(TitleContentType),
/// Dual title content, will align first content for default panel size width. /// Dual title content, will align first content for default panel size width.
Dual(TitleContentType, TitleContentType), Dual(TitleContentType, TitleContentType),
} }
/// Title content type, can be single title or with animated subtitle. /// Title content type, can be single title or with animated subtitle.
pub enum TitleContentType { pub enum TitleContentType {
/// Single text. /// Single text.
Title(String), Title(String),
/// With optionally animated subtitle text. /// With optionally animated subtitle text.
WithSubTitle(String, String, bool) WithSubTitle(String, String, bool),
}
/// Stroke position against content.
pub enum LinePosition {
TOP,
LEFT,
RIGHT,
BOTTOM,
} }
/// Position of [`Modal`] on the screen. /// Position of [`Modal`] on the screen.
#[derive(Clone)] #[derive(Clone)]
pub enum ModalPosition { pub enum ModalPosition {
CenterTop, CenterTop,
Center Center,
} }
/// Global [`Modal`] state. /// Global [`Modal`] state.
#[derive(Default)] #[derive(Default)]
pub struct ModalState { pub struct ModalState {
/// Opened [`Modal`]. /// Opened [`Modal`].
pub modal: Option<Modal>, pub modal: Option<Modal>,
} }
/// Contains identifiers to draw opened [`Modal`] content for current ui container. /// Content container to simplify modals management and navigation.
pub trait ModalContainer { pub trait ContentContainer {
/// List of allowed [`Modal`] identifiers. /// List of allowed [`Modal`] identifiers.
fn modal_ids(&self) -> &Vec<&'static str>; fn modal_ids(&self) -> Vec<&'static str>;
/// Draw modal content.
/// Draw modal ui content. fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks);
fn modal_ui(&mut self, /// Draw container content.
ui: &mut egui::Ui, fn container_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks);
modal: &Modal, /// Draw content, to call by parent container.
cb: &dyn PlatformCallbacks); fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
// Draw modal content.
/// Draw [`Modal`] for current ui container if it's possible. if let Some(id) = Modal::opened() {
fn current_modal_ui(&mut self, if self.modal_ids().contains(&id) {
ui: &mut egui::Ui, Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
cb: &dyn PlatformCallbacks) { self.modal_ui(ui, modal, cb);
let modal_id = Modal::opened(); });
let draw = modal_id.is_some() && self.modal_ids().contains(&modal_id.unwrap()); }
if draw { }
Modal::ui(ui.ctx(), |ui, modal| { self.container_ui(ui, cb);
self.modal_ui(ui, modal, cb); }
});
}
}
}
/// Options for [`egui::TextEdit`] view.
pub struct TextEditOptions {
/// View identifier.
pub id: egui::Id,
/// Check if horizontal centering is needed.
pub h_center: bool,
/// Check if initial focus on field is needed.
pub focus: bool,
/// Hide letters and draw button to show/hide letters.
pub password: bool,
/// Show copy button.
pub copy: bool,
/// Show paste button.
pub paste: bool,
/// Show button to scan QR code into text.
pub scan_qr: bool,
/// Callback when scan button was pressed.
pub scan_pressed: bool,
}
impl TextEditOptions {
pub fn new(id: egui::Id) -> Self {
Self {
id,
h_center: false,
focus: true,
password: false,
copy: false,
paste: false,
scan_qr: false,
scan_pressed: false,
}
}
/// Center text horizontally.
pub fn h_center(mut self) -> Self {
self.h_center = true;
self
}
/// Disable initial focus.
pub fn no_focus(mut self) -> Self {
self.focus = false;
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 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
}
} }
/// QR code scan result. /// QR code scan result.
#[derive(Clone)] #[derive(Clone)]
pub enum QrScanResult { pub enum QrScanResult {
/// Slatepack message. /// Slatepack message.
Slatepack(ZeroingString), Slatepack(String),
/// Slatepack address. /// Slatepack address.
Address(ZeroingString), Address(ZeroingString),
/// Parsed text. /// Parsed text.
Text(ZeroingString), Text(ZeroingString),
/// Recovery phrase in standard or compact SeedQR format. /// Recovery phrase in standard or compact SeedQR format.
/// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md /// https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md
SeedQR(ZeroingString), SeedQR(ZeroingString),
/// Part of Uniform Resources as URI with current index and total messages amount. /// Part of Uniform Resources as URI with current index and total messages amount.
/// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md /// https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md
URPart(String, usize, usize), URPart(String, usize, usize),
} }
impl QrScanResult { impl QrScanResult {
/// Get text scanning result. /// Get text scanning result.
pub fn text(&self) -> String { pub fn text(&self) -> String {
match self { match self {
QrScanResult::Slatepack(text) => text.to_string(), QrScanResult::Slatepack(text) => text.to_string(),
QrScanResult::Address(text) => text.to_string(), QrScanResult::Address(text) => text.to_string(),
QrScanResult::Text(text) => text.to_string(), QrScanResult::Text(text) => text.to_string(),
QrScanResult::SeedQR(text) => text.to_string(), QrScanResult::SeedQR(text) => text.to_string(),
QrScanResult::URPart(uri, _, _) => uri.to_string(), QrScanResult::URPart(uri, _, _) => uri.to_string(),
} }
} }
} }
/// QR code scanning state. /// QR code scanning state.
pub struct QrScanState { pub struct QrScanState {
/// Flag to check if image is processing to find QR code. /// Flag to check if image is processing to find QR code.
pub image_processing: bool, pub image_processing: bool,
/// Processed QR code result. /// Processed QR code result.
pub qr_scan_result: Option<QrScanResult> pub qr_scan_result: Option<QrScanResult>,
} }
impl Default for QrScanState { impl Default for QrScanState {
fn default() -> Self { fn default() -> Self {
Self { Self {
image_processing: false, image_processing: false,
qr_scan_result: None, qr_scan_result: None,
} }
} }
} }
/// QR code image data state. /// QR code image data state.
pub struct QrImageState { pub struct QrImageState {
/// Flag to check if QR code image is loading. /// Flag to check if QR code image is loading.
pub loading: bool, pub loading: bool,
/// Flag to check if QR code image is exporting. /// Flag to check if QR code image is exporting.
pub exporting: bool, pub exporting: bool,
/// Created GIF data from animated QR code. /// Created GIF data from animated QR code.
pub gif_data: Option<Vec<u8>>, pub gif_data: Option<Vec<u8>>,
/// Flag to check if GIF is creating. /// Flag to check if GIF is creating.
pub gif_creating: bool, pub gif_creating: bool,
/// Vector image data. /// Vector image data.
pub svg: Option<Vec<u8>>, pub svg: Option<Vec<u8>>,
/// Multiple vector image data for animated QR code. /// Multiple vector image data for animated QR code.
pub svg_list: Option<Vec<Vec<u8>>> pub svg_list: Option<Vec<Vec<u8>>>,
} }
impl Default for QrImageState { impl Default for QrImageState {
fn default() -> Self { fn default() -> Self {
Self { Self {
loading: false, loading: false,
exporting: false, exporting: false,
gif_data: None, gif_data: None,
gif_creating: false, gif_creating: false,
svg: None, svg: None,
svg_list: None, svg_list: None,
} }
} }
} }
+753 -728
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+376
View File
@@ -0,0 +1,376 @@
// Copyright 2023 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::scroll_area::ScrollBarVisibility;
use egui::{Id, Margin, RichText, ScrollArea};
use grin_util::ZeroingString;
use crate::gui::Colors;
use crate::gui::icons::{CHECK, CLIPBOARD_TEXT, COPY, SCAN};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::types::{ContentContainer, LinePosition, ModalPosition, QrScanResult};
use crate::gui::views::wallets::ConnectionSettings;
use crate::gui::views::wallets::creation::MnemonicSetup;
use crate::gui::views::wallets::creation::types::Step;
use crate::gui::views::{CameraScanContent, Content, Modal, View};
use crate::node::Node;
use crate::wallet::types::PhraseMode;
use crate::wallet::{ExternalConnection, Wallet};
/// Wallet creation content.
pub struct WalletCreationContent {
/// Wallet name.
pub name: String,
/// Wallet password.
pub pass: ZeroingString,
/// Wallet creation step.
step: Step,
/// QR code scanning [`Modal`] content.
scan_modal_content: Option<CameraScanContent>,
/// Mnemonic phrase setup content.
mnemonic_setup: MnemonicSetup,
/// Network setup content.
network_setup: ConnectionSettings,
/// Flag to check if an error occurred during wallet creation.
creation_error: Option<String>,
}
const QR_CODE_PHRASE_SCAN_MODAL: &'static str = "qr_code_rec_phrase_scan_modal";
impl ContentContainer for WalletCreationContent {
fn modal_ids(&self) -> Vec<&'static str> {
vec![QR_CODE_PHRASE_SCAN_MODAL]
}
fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
match modal.id {
QR_CODE_PHRASE_SCAN_MODAL => {
if let Some(content) = self.scan_modal_content.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();
}
_ => {}
});
}
}
_ => {}
}
}
fn container_ui(&mut self, _: &mut egui::Ui, _: &dyn PlatformCallbacks) {}
}
impl WalletCreationContent {
/// Create new wallet creation content from name and password.
pub fn new(name: String, pass: ZeroingString) -> Self {
Self {
name,
pass,
step: Step::EnterMnemonic,
scan_modal_content: None,
mnemonic_setup: MnemonicSetup::default(),
network_setup: ConnectionSettings::default(),
creation_error: None,
}
}
/// Draw wallet creation content.
pub fn content_ui(
&mut self,
ui: &mut egui::Ui,
cb: &dyn PlatformCallbacks,
on_create: impl FnMut(Wallet),
) {
self.ui(ui, cb);
egui::TopBottomPanel::bottom("wallet_creation_step_panel")
.frame(egui::Frame {
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + View::TAB_ITEMS_PADDING) as i8,
right: (View::get_right_inset() + View::TAB_ITEMS_PADDING) as i8,
top: View::TAB_ITEMS_PADDING as i8,
bottom: (View::get_bottom_inset() + View::TAB_ITEMS_PADDING) as i8,
},
fill: Colors::fill(),
..Default::default()
})
.show_inside(ui, |ui| {
// Draw divider line.
let rect = {
let mut r = ui.available_rect_before_wrap();
r.min.y -= View::TAB_ITEMS_PADDING;
r.min.x -= View::far_left_inset_margin(ui) + View::TAB_ITEMS_PADDING;
r.max.x += View::get_right_inset() + View::TAB_ITEMS_PADDING;
r
};
View::line(ui, LinePosition::TOP, &rect, Colors::item_stroke());
// Show step control content.
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 1.3, |ui| {
self.step_control_ui(ui, on_create, cb);
});
});
// Show wallet creation step content panel.
egui::CentralPanel::default()
.frame(egui::Frame {
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 4.0) as i8,
right: (View::get_right_inset() + 4.0) as i8,
top: 3.0 as i8,
bottom: 4.0 as i8,
},
fill: Colors::fill_lite(),
..Default::default()
})
.show_inside(ui, |ui| {
ScrollArea::vertical()
.id_salt(Id::from(format!(
"creation_step_scroll_{}",
self.step.name()
)))
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
let max_width = if self.step == Step::SetupConnection {
Content::SIDE_PANEL_WIDTH * 1.3
} else {
Content::SIDE_PANEL_WIDTH * 2.0
};
View::max_width_ui(ui, max_width, |ui| {
self.step_content_ui(ui, cb);
});
});
});
}
/// Draw [`Step`] description and confirmation control.
fn step_control_ui(
&mut self,
ui: &mut egui::Ui,
on_create: impl FnOnce(Wallet),
cb: &dyn PlatformCallbacks,
) {
let step = &self.step;
// Setup description and next step availability.
let (step_text, mut next) = match step {
Step::EnterMnemonic => {
let mode = &self.mnemonic_setup.mnemonic.mode();
let (text, available) = match mode {
PhraseMode::Generate => (t!("wallets.create_phrase_desc"), true),
PhraseMode::Import => {
let available = !self.mnemonic_setup.mnemonic.has_empty_or_invalid();
(t!("wallets.restore_phrase_desc"), available)
}
};
(text, available)
}
Step::ConfirmMnemonic => {
let text = t!("wallets.restore_phrase_desc");
let available = !self.mnemonic_setup.mnemonic.has_empty_or_invalid();
(text, available)
}
Step::SetupConnection => (t!("wallets.setup_conn_desc"), self.creation_error.is_none()),
};
// Show step description or error.
let generate_step = step == &Step::EnterMnemonic
&& self.mnemonic_setup.mnemonic.mode() == PhraseMode::Generate;
if (self.mnemonic_setup.mnemonic.valid() && self.creation_error.is_none()) || generate_step
{
ui.label(RichText::new(step_text).size(16.0).color(Colors::gray()));
ui.add_space(6.0);
} else {
next = false;
// Show error text.
if let Some(err) = &self.creation_error {
ui.add_space(10.0);
ui.label(RichText::new(err).size(16.0).color(Colors::red()));
ui.add_space(10.0);
} else {
ui.label(
RichText::new(t!("wallets.not_valid_phrase"))
.size(16.0)
.color(Colors::red()),
);
ui.add_space(4.0);
};
}
// Setup spacing between buttons.
ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 0.0);
// Setup vertical padding inside button.
ui.style_mut().spacing.button_padding = egui::vec2(10.0, 7.0);
match step {
Step::EnterMnemonic => {
ui.columns(2, |columns| {
// Show copy or paste button for mnemonic phrase step.
columns[0].vertical_centered_justified(|ui| {
match self.mnemonic_setup.mnemonic.mode() {
PhraseMode::Generate => {
let c_t = format!("{} {}", COPY, t!("copy"));
View::button(ui, c_t, Colors::white_or_black(false), || {
cb.copy_string_to_buffer(
self.mnemonic_setup.mnemonic.get_phrase(),
);
});
}
PhraseMode::Import => {
let p_t = format!("{} {}", CLIPBOARD_TEXT, t!("paste"));
View::button(ui, p_t, Colors::white_or_black(false), || {
let data = ZeroingString::from(cb.get_string_from_buffer());
self.mnemonic_setup.mnemonic.import(&data);
});
}
}
});
// Show next step or QR code scan button.
columns[1].vertical_centered_justified(|ui| {
if next {
self.next_step_button_ui(ui, on_create);
} else {
let scan_text = format!("{} {}", SCAN, t!("scan"));
View::button(ui, scan_text, Colors::white_or_black(false), || {
self.scan_modal_content = Some(CameraScanContent::default());
// Show QR code scan modal.
Modal::new(QR_CODE_PHRASE_SCAN_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("scan_qr"))
.closeable(false)
.show();
cb.start_camera();
});
}
});
});
}
Step::ConfirmMnemonic => {
// Show next step or paste button.
if next {
self.next_step_button_ui(ui, on_create);
} else {
let paste_text = format!("{} {}", CLIPBOARD_TEXT, t!("paste"));
View::button(ui, paste_text, Colors::white_or_black(false), || {
let data = ZeroingString::from(cb.get_string_from_buffer());
self.mnemonic_setup.mnemonic.import(&data);
});
}
}
Step::SetupConnection => {
if next {
self.next_step_button_ui(ui, on_create);
ui.add_space(2.0);
}
}
}
}
/// Draw button to go to next [`Step`].
fn next_step_button_ui(&mut self, ui: &mut egui::Ui, on_create: impl FnOnce(Wallet)) {
// Setup button text.
let (next_text, text_color, bg_color) = if self.step == Step::SetupConnection {
(
format!("{} {}", CHECK, t!("complete")),
Colors::title(true),
Colors::gold(),
)
} else {
(
t!("continue").into(),
Colors::green(),
Colors::white_or_black(false),
)
};
// Show next step button.
View::colored_text_button_ui(ui, next_text.to_uppercase(), text_color, bg_color, |ui| {
self.step = match self.step {
Step::EnterMnemonic => {
if self.mnemonic_setup.mnemonic.mode() == PhraseMode::Generate {
Step::ConfirmMnemonic
} else {
Step::SetupConnection
}
}
Step::ConfirmMnemonic => Step::SetupConnection,
Step::SetupConnection => {
// Create wallet at last step.
match Wallet::create(
&self.name,
&self.pass,
&self.mnemonic_setup.mnemonic,
&self.network_setup.method,
) {
Ok(w) => {
self.mnemonic_setup.reset();
// Pass created wallet to callback.
(on_create)(w);
Step::EnterMnemonic
}
Err(e) => {
self.creation_error = Some(format!("{:?}", e));
Step::SetupConnection
}
}
}
};
// Check external connections availability on connection setup.
if self.step == Step::SetupConnection {
ExternalConnection::check(None, ui.ctx());
}
});
}
/// Draw wallet creation [`Step`] content.
fn step_content_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
match &self.step {
Step::EnterMnemonic => self.mnemonic_setup.enter_ui(ui, cb),
Step::ConfirmMnemonic => self.mnemonic_setup.confirm_ui(ui, cb),
Step::SetupConnection => {
// Redraw if node is running.
if Node::is_running() && !Content::is_dual_panel_mode(ui.ctx()) {
ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY);
}
self.network_setup.ui(ui, cb);
}
}
}
/// Back to previous wallet creation [`Step`], return `true` to close creation.
pub fn on_back(&mut self) -> bool {
match &self.step {
Step::ConfirmMnemonic => {
self.step = Step::EnterMnemonic;
false
}
Step::SetupConnection => {
self.creation_error = None;
self.step = Step::EnterMnemonic;
false
}
_ => true,
}
}
}
-459
View File
@@ -1,459 +0,0 @@
// Copyright 2023 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::{Id, Margin, RichText, ScrollArea};
use egui::scroll_area::ScrollBarVisibility;
use grin_util::ZeroingString;
use crate::gui::Colors;
use crate::gui::icons::{CHECK, CLIPBOARD_TEXT, COPY, FOLDER_PLUS, SCAN, SHARE_FAT};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, Content, View};
use crate::gui::views::types::{ModalPosition, TextEditOptions};
use crate::gui::views::wallets::creation::MnemonicSetup;
use crate::gui::views::wallets::creation::types::Step;
use crate::gui::views::wallets::settings::ConnectionSettings;
use crate::node::Node;
use crate::wallet::{ExternalConnection, Wallet};
use crate::wallet::types::PhraseMode;
/// Wallet creation content.
pub struct WalletCreation {
/// Wallet creation step.
step: Option<Step>,
/// Flag to check if wallet creation [`Modal`] was just opened to focus on first field.
modal_just_opened: bool,
/// Wallet name value.
name_edit: String,
/// Password to encrypt created wallet.
pass_edit: String,
/// Mnemonic phrase setup content.
pub(crate) mnemonic_setup: MnemonicSetup,
/// Network setup content.
pub(crate) network_setup: ConnectionSettings
}
impl Default for WalletCreation {
fn default() -> Self {
Self {
step: None,
modal_just_opened: true,
name_edit: String::from(""),
pass_edit: String::from(""),
mnemonic_setup: MnemonicSetup::default(),
network_setup: ConnectionSettings::default()
}
}
}
impl WalletCreation {
/// Wallet name/password input modal identifier.
pub const NAME_PASS_MODAL: &'static str = "name_pass_modal";
/// Draw wallet creation content.
pub fn ui(&mut self,
ui: &mut egui::Ui,
cb: &dyn PlatformCallbacks,
on_create: impl FnOnce(Wallet)) {
// Show wallet creation step description and confirmation panel.
if self.step.is_some() {
egui::TopBottomPanel::bottom("wallet_creation_step_panel")
.frame(egui::Frame {
fill: Colors::fill(),
inner_margin: Margin {
left: View::far_left_inset_margin(ui) + 8.0,
right: View::get_right_inset() + 8.0,
top: 4.0,
bottom: View::get_bottom_inset(),
},
..Default::default()
})
.show_inside(ui, |ui| {
ui.vertical_centered(|ui| {
ui.vertical_centered(|ui| {
View::max_width_ui(ui, Content::SIDE_PANEL_WIDTH * 2.0, |ui| {
self.step_control_ui(ui, on_create, cb);
});
});
});
});
}
// Show wallet creation step content panel.
egui::CentralPanel::default()
.frame(egui::Frame {
stroke: View::item_stroke(),
inner_margin: Margin {
left: View::far_left_inset_margin(ui) + 4.0,
right: View::get_right_inset() + 4.0,
top: 3.0,
bottom: 4.0,
},
..Default::default()
})
.show_inside(ui, |ui| {
let id = if let Some(step) = &self.step {
format!("creation_step_scroll_{}", step.name())
} else {
"creation_step_scroll".to_owned()
};
ScrollArea::vertical()
.id_source(id)
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.vertical_centered(|ui| {
let max_width = if self.step == Some(Step::SetupConnection) {
Content::SIDE_PANEL_WIDTH * 1.3
} else {
Content::SIDE_PANEL_WIDTH * 2.0
};
View::max_width_ui(ui, max_width, |ui| {
self.step_content_ui(ui, cb);
});
});
});
});
}
/// Draw [`Step`] description and confirmation control.
fn step_control_ui(&mut self,
ui: &mut egui::Ui,
on_create: impl FnOnce(Wallet),
cb: &dyn PlatformCallbacks) {
if let Some(step) = self.step.clone() {
// Setup step description text and availability.
let (step_text, mut step_available) = match step {
Step::EnterMnemonic => {
let mode = &self.mnemonic_setup.mnemonic.mode;
let text = if mode == &PhraseMode::Generate {
t!("wallets.create_phrase_desc")
} else {
t!("wallets.restore_phrase_desc")
};
let available = !self
.mnemonic_setup
.mnemonic
.words
.contains(&String::from(""));
(text, available)
}
Step::ConfirmMnemonic => {
let text = t!("wallets.restore_phrase_desc");
let available = !self
.mnemonic_setup
.mnemonic
.confirm_words
.contains(&String::from(""));
(text, available)
},
Step::SetupConnection => (t!("wallets.setup_conn_desc"), true)
};
// Show step description.
ui.add_space(2.0);
ui.label(RichText::new(step_text).size(16.0).color(Colors::gray()));
ui.add_space(2.0);
// Show error if entered phrase is not valid.
if !self.mnemonic_setup.valid_phrase {
step_available = false;
ui.label(RichText::new(t!("wallets.not_valid_phrase"))
.size(16.0)
.color(Colors::red()));
ui.add_space(2.0);
}
if step == Step::EnterMnemonic {
ui.add_space(4.0);
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
// Show copy or paste button for mnemonic phrase step.
columns[0].vertical_centered_justified(|ui| {
self.copy_or_paste_button_ui(ui, cb);
});
columns[1].vertical_centered_justified(|ui| {
if step_available {
// Show next step button if there are no empty words.
self.next_step_button_ui(ui, step, on_create);
} else {
// Show QR code scan button.
let scan_text = format!("{} {}", SCAN, t!("scan").to_uppercase());
View::button(ui, scan_text, Colors::white_or_black(false), || {
self.mnemonic_setup.show_qr_scan_modal(cb);
});
}
});
});
ui.add_space(4.0);
} else if step == Step::ConfirmMnemonic {
ui.add_space(4.0);
// Show next step or paste button.
if step_available {
self.next_step_button_ui(ui, step, on_create);
} else {
let paste_text = format!("{} {}", CLIPBOARD_TEXT, t!("paste").to_uppercase());
View::button(ui, paste_text, Colors::white_or_black(false), || {
let data = ZeroingString::from(cb.get_string_from_buffer().trim());
self.mnemonic_setup.mnemonic.import_text(&data, true);
});
}
ui.add_space(4.0);
} else if step_available {
ui.add_space(4.0);
self.next_step_button_ui(ui, step, on_create);
ui.add_space(4.0);
}
ui.add_space(4.0);
}
}
/// Draw copy or paste button at [`Step::EnterMnemonic`].
fn copy_or_paste_button_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
match self.mnemonic_setup.mnemonic.mode {
PhraseMode::Generate => {
// Show copy button.
let c_t = format!("{} {}", COPY, t!("copy").to_uppercase());
View::button(ui, c_t.to_uppercase(), Colors::white_or_black(false), || {
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
});
}
PhraseMode::Import => {
// Show paste button.
let p_t = format!("{} {}", CLIPBOARD_TEXT, t!("paste").to_uppercase());
View::button(ui, p_t, Colors::white_or_black(false), || {
let data = ZeroingString::from(cb.get_string_from_buffer().trim());
self.mnemonic_setup.mnemonic.import_text(&data, false);
});
}
}
}
/// Draw button to go to next [`Step`].
fn next_step_button_ui(&mut self,
ui: &mut egui::Ui,
step: Step,
on_create: impl FnOnce(Wallet)) {
// Setup button text.
let (next_text, text_color, bg_color) = if step == Step::SetupConnection {
(format!("{} {}", CHECK, t!("complete")), Colors::title(true), Colors::gold())
} else {
let text = format!("{} {}", SHARE_FAT, t!("continue"));
(text, Colors::text_button(), Colors::white_or_black(false))
};
// Show next step button.
View::colored_text_button(ui, next_text.to_uppercase(), text_color, bg_color, || {
self.step = if let Some(step) = &self.step {
match step {
Step::EnterMnemonic => {
if self.mnemonic_setup.mnemonic.mode == PhraseMode::Generate {
Some(Step::ConfirmMnemonic)
} else {
// Check if entered phrase was valid.
if self.mnemonic_setup.valid_phrase {
Some(Step::SetupConnection)
} else {
Some(Step::EnterMnemonic)
}
}
}
Step::ConfirmMnemonic => {
Some(Step::SetupConnection)
},
Step::SetupConnection => {
// Create wallet at last step.
let name = self.name_edit.clone();
let pass = self.pass_edit.clone();
let phrase = self.mnemonic_setup.mnemonic.get_phrase();
let conn_method = &self.network_setup.method;
let mut wallet = Wallet::create(name,
pass.clone(),
phrase,
conn_method).unwrap();
// Open created wallet.
wallet.open(pass).unwrap();
// Pass created wallet to callback.
(on_create)(wallet);
// Reset input data.
self.step = None;
self.name_edit = String::from("");
self.pass_edit = String::from("");
self.mnemonic_setup.reset();
None
}
}
} else {
Some(Step::EnterMnemonic)
};
// Check external connections availability on connection setup.
if self.step == Some(Step::SetupConnection) {
ExternalConnection::check_ext_conn_availability(None);
}
});
}
/// Draw wallet creation [`Step`] content.
fn step_content_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
match &self.step {
None => {
// Show wallet creation message if step is empty.
View::center_content(ui, 350.0 + View::get_bottom_inset(), |ui| {
// Show app logo.
View::app_logo_name_version(ui);
ui.add_space(4.0);
let text = t!("wallets.create_desc");
ui.label(RichText::new(text)
.size(16.0)
.color(Colors::gray())
);
ui.add_space(8.0);
// Show wallet creation button.
let add_text = format!("{} {}", FOLDER_PLUS, t!("wallets.add"));
View::button(ui, add_text, Colors::white_or_black(false), || {
self.show_name_pass_modal(cb);
});
});
}
Some(step) => {
match step {
Step::EnterMnemonic => self.mnemonic_setup.ui(ui, cb),
Step::ConfirmMnemonic => self.mnemonic_setup.confirm_ui(ui, cb),
Step::SetupConnection => {
// Redraw if node is running.
if Node::is_running() {
ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY);
}
self.network_setup.create_ui(ui, cb)
}
}
}
}
}
/// Check if it's possible to go back for current step.
pub fn can_go_back(&self) -> bool {
self.step.is_some()
}
/// Back to previous wallet creation [`Step`].
pub fn back(&mut self) {
match &self.step {
None => {}
Some(step) => {
match step {
Step::EnterMnemonic => {
self.step = None;
self.name_edit = String::from("");
self.pass_edit = String::from("");
self.mnemonic_setup.reset();
},
Step::ConfirmMnemonic => self.step = Some(Step::EnterMnemonic),
Step::SetupConnection => self.step = Some(Step::EnterMnemonic)
}
}
}
}
/// Start wallet creation from showing [`Modal`] to enter name and password.
pub fn show_name_pass_modal(&mut self, cb: &dyn PlatformCallbacks) {
// Reset modal values.
self.modal_just_opened = true;
self.name_edit = t!("wallets.default_wallet");
self.pass_edit = String::from("");
// Show modal.
Modal::new(Self::NAME_PASS_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("wallets.add"))
.show();
cb.show_keyboard();
}
/// Draw creating wallet name/password input [`Modal`] content.
pub fn name_pass_modal_ui(&mut self,
ui: &mut egui::Ui,
modal: &Modal,
cb: &dyn PlatformCallbacks) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("wallets.name"))
.size(17.0)
.color(Colors::gray()));
ui.add_space(8.0);
// Show wallet name text edit.
let mut name_edit_opts = TextEditOptions::new(Id::from(modal.id).with("name"))
.no_focus();
if self.modal_just_opened {
self.modal_just_opened = false;
name_edit_opts.focus = true;
}
View::text_edit(ui, cb, &mut self.name_edit, &mut name_edit_opts);
ui.add_space(8.0);
ui.label(RichText::new(t!("wallets.pass"))
.size(17.0)
.color(Colors::gray()));
ui.add_space(8.0);
// Draw wallet password text edit.
let mut pass_text_edit_opts = TextEditOptions::new(Id::from(modal.id).with("pass"))
.password()
.no_focus();
View::text_edit(ui, cb, &mut self.pass_edit, &mut pass_text_edit_opts);
ui.add_space(12.0);
});
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
// Close modal.
cb.hide_keyboard();
modal.close();
});
});
columns[1].vertical_centered_justified(|ui| {
let mut on_next = || {
// Check if input values are not empty.
if self.name_edit.is_empty() || self.pass_edit.is_empty() {
return;
}
self.step = Some(Step::EnterMnemonic);
cb.hide_keyboard();
modal.close();
};
// Go to next creation step on Enter button press.
View::on_enter_key(ui, || {
(on_next)();
});
View::button(ui, t!("continue"), Colors::white_or_black(false), on_next);
});
});
ui.add_space(6.0);
});
}
}
+266 -389
View File
@@ -17,430 +17,307 @@ use egui::{Id, RichText};
use crate::gui::Colors; use crate::gui::Colors;
use crate::gui::icons::PENCIL; use crate::gui::icons::PENCIL;
use crate::gui::platform::PlatformCallbacks; use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{CameraContent, Modal, Content, View}; use crate::gui::views::types::{ContentContainer, ModalPosition};
use crate::gui::views::types::{ModalContainer, ModalPosition, QrScanResult, TextEditOptions}; use crate::gui::views::{Content, Modal, TextEdit, View};
use crate::wallet::Mnemonic; use crate::wallet::Mnemonic;
use crate::wallet::types::{PhraseMode, PhraseSize}; use crate::wallet::types::{PhraseMode, PhraseSize, PhraseWord};
/// Mnemonic phrase setup content. /// Mnemonic phrase setup content.
pub struct MnemonicSetup { pub struct MnemonicSetup {
/// Current mnemonic phrase. /// Current mnemonic phrase.
pub(crate) mnemonic: Mnemonic, pub mnemonic: Mnemonic,
/// Flag to check if entered phrase was valid. /// Current word number to edit at [`Modal`].
pub(crate) valid_phrase: bool, word_index_edit: usize,
/// Entered word value for [`Modal`].
/// Current word number to edit at [`Modal`]. word_edit: String,
word_num_edit: usize, /// Flag to check if entered word is valid at [`Modal`].
/// Entered word value for [`Modal`]. valid_word_edit: bool,
word_edit: String,
/// Flag to check if entered word is valid.
valid_word_edit: bool,
/// Camera content for QR scan [`Modal`].
camera_content: CameraContent,
/// Flag to check if recovery phrase was found at QR code scanning [`Modal`].
scan_phrase_not_found: Option<bool>,
/// [`Modal`] identifiers allowed at this ui container.
modal_ids: Vec<&'static str>
} }
/// Identifier for word input [`Modal`]. /// Identifier for word input [`Modal`].
pub const WORD_INPUT_MODAL: &'static str = "word_input_modal"; pub const WORD_INPUT_MODAL: &'static str = "word_input_modal";
/// Identifier for QR code recovery phrase scan [`Modal`].
const QR_CODE_PHRASE_SCAN_MODAL: &'static str = "qr_code_rec_phrase_scan_modal";
impl Default for MnemonicSetup { impl Default for MnemonicSetup {
fn default() -> Self { fn default() -> Self {
Self { Self {
mnemonic: Mnemonic::default(), mnemonic: Mnemonic::default(),
valid_phrase: true, word_index_edit: 0,
word_num_edit: 0, word_edit: String::from(""),
word_edit: String::from(""), valid_word_edit: true,
valid_word_edit: true, }
camera_content: CameraContent::default(), }
scan_phrase_not_found: None,
modal_ids: vec![
WORD_INPUT_MODAL,
QR_CODE_PHRASE_SCAN_MODAL
]
}
}
} }
impl ModalContainer for MnemonicSetup { impl ContentContainer for MnemonicSetup {
fn modal_ids(&self) -> &Vec<&'static str> { fn modal_ids(&self) -> Vec<&'static str> {
&self.modal_ids vec![WORD_INPUT_MODAL]
} }
fn modal_ui(&mut self, fn modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
ui: &mut egui::Ui, match modal.id {
modal: &Modal, WORD_INPUT_MODAL => self.word_modal_ui(ui, modal, cb),
cb: &dyn PlatformCallbacks) { _ => {}
match modal.id { }
WORD_INPUT_MODAL => self.word_modal_ui(ui, modal, cb), }
QR_CODE_PHRASE_SCAN_MODAL => self.scan_qr_modal_ui(ui, modal, cb),
_ => {} fn container_ui(&mut self, _: &mut egui::Ui, _: &dyn PlatformCallbacks) {}
}
}
} }
impl MnemonicSetup { impl MnemonicSetup {
/// Draw content for phrase input step. /// Draw content for phrase enter step.
pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { pub fn enter_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
// Draw modal content for current ui container. self.ui(ui, cb);
self.current_modal_ui(ui, cb); ui.add_space(10.0);
ui.add_space(10.0); // Show mode setup.
let mut mode = self.mnemonic.mode();
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
let create_mode = PhraseMode::Generate;
let create_text = t!("create");
View::radio_value(ui, &mut mode, create_mode, create_text);
});
columns[1].vertical_centered(|ui| {
let import_mode = PhraseMode::Import;
let import_text = t!("wallets.recover");
View::radio_value(ui, &mut mode, import_mode, import_text);
});
});
if mode != self.mnemonic.mode() {
self.mnemonic.set_mode(mode);
}
// Show mode and type setup. ui.add_space(10.0);
self.mode_type_ui(ui); ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("wallets.words_count"))
.size(16.0)
.color(Colors::gray()),
);
});
ui.add_space(6.0);
ui.add_space(12.0); // Show mnemonic phrase size setup.
View::horizontal_line(ui, Colors::item_stroke()); let mut size = self.mnemonic.size();
ui.add_space(6.0); ui.columns(5, |columns| {
for (index, word) in PhraseSize::VALUES.iter().enumerate() {
columns[index].vertical_centered(|ui| {
let text = word.value().to_string();
View::radio_value(ui, &mut size, word.clone(), text);
});
}
});
if size != self.mnemonic.size() {
self.mnemonic.set_size(size);
}
// Show words setup. ui.add_space(12.0);
self.word_list_ui(ui, self.mnemonic.mode == PhraseMode::Import, cb); View::horizontal_line(ui, Colors::item_stroke());
} ui.add_space(6.0);
/// Draw content for phrase confirmation step. // Show words setup.
pub fn confirm_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { self.word_list_ui(ui, self.mnemonic.mode() == PhraseMode::Import);
// Draw modal content for current ui container. }
self.current_modal_ui(ui, cb); /// Draw content for phrase confirmation step.
pub fn confirm_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
self.ui(ui, cb);
ui.add_space(4.0); ui.add_space(4.0);
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
let text = format!("{}:", t!("wallets.recovery_phrase")); let text = format!("{}:", t!("wallets.recovery_phrase"));
ui.label(RichText::new(text).size(16.0).color(Colors::gray())); ui.label(RichText::new(text).size(16.0).color(Colors::gray()));
}); });
ui.add_space(4.0); ui.add_space(4.0);
self.word_list_ui(ui, true, cb); self.word_list_ui(ui, true);
} }
/// Draw mode and size setup. /// Draw grid of words for mnemonic phrase.
fn mode_type_ui(&mut self, ui: &mut egui::Ui) { fn word_list_ui(&mut self, ui: &mut egui::Ui, edit: bool) {
// Show mode setup. ui.add_space(6.0);
let mut mode = self.mnemonic.mode.clone(); ui.scope(|ui| {
ui.columns(2, |columns| { // Setup spacing between columns.
columns[0].vertical_centered(|ui| { ui.spacing_mut().item_spacing = egui::Vec2::new(6.0, 6.0);
let create_mode = PhraseMode::Generate;
let create_text = t!("create");
View::radio_value(ui, &mut mode, create_mode, create_text);
});
columns[1].vertical_centered(|ui| {
let import_mode = PhraseMode::Import;
let import_text = t!("wallets.recover");
View::radio_value(ui, &mut mode, import_mode, import_text);
});
});
if mode != self.mnemonic.mode {
self.mnemonic.set_mode(mode)
}
ui.add_space(10.0); // Select list of words based on current mode and edit flag.
ui.vertical_centered(|ui| { let words = self.mnemonic.words(edit);
ui.label(RichText::new(t!("wallets.words_count"))
.size(16.0)
.color(Colors::gray())
);
});
ui.add_space(6.0);
// Show mnemonic phrase size setup. let mut word_number = 0;
let mut size = self.mnemonic.size.clone(); let cols = list_columns_count(ui);
ui.columns(5, |columns| { let _ = words
for (index, word) in PhraseSize::VALUES.iter().enumerate() { .chunks(cols)
columns[index].vertical_centered(|ui| { .map(|chunk| {
let text = word.value().to_string(); let size = chunk.len();
View::radio_value(ui, &mut size, word.clone(), text); word_number += 1;
}); if size > 1 {
} ui.columns(cols, |columns| {
}); columns[0].horizontal(|ui| {
if size != self.mnemonic.size { let word = chunk.get(0).unwrap();
self.mnemonic.set_size(size); self.word_item_ui(ui, word_number, word, edit);
} });
} columns[1].horizontal(|ui| {
word_number += 1;
let word = chunk.get(1).unwrap();
self.word_item_ui(ui, word_number, word, edit);
});
if size > 2 {
columns[2].horizontal(|ui| {
word_number += 1;
let word = chunk.get(2).unwrap();
self.word_item_ui(ui, word_number, word, edit);
});
}
if size > 3 {
columns[3].horizontal(|ui| {
word_number += 1;
let word = chunk.get(3).unwrap();
self.word_item_ui(ui, word_number, word, edit);
});
}
});
} else {
ui.columns(cols, |columns| {
columns[0].horizontal(|ui| {
let word = chunk.get(0).unwrap();
self.word_item_ui(ui, word_number, word, edit);
});
});
}
})
.collect::<Vec<_>>();
});
ui.add_space(6.0);
}
/// Draw list of words for mnemonic phrase. /// Draw word grid item.
fn word_list_ui(&mut self, ui: &mut egui::Ui, edit_words: bool, cb: &dyn PlatformCallbacks) { fn word_item_ui(&mut self, ui: &mut egui::Ui, num: usize, word: &PhraseWord, edit: bool) {
ui.add_space(6.0); let color = if !word.valid || (word.text.is_empty() && !self.mnemonic.valid()) {
ui.scope(|ui| { Colors::red()
// Setup spacing between columns. } else {
ui.spacing_mut().item_spacing = egui::Vec2::new(6.0, 6.0); Colors::white_or_black(true)
};
if edit {
ui.add_space(6.0);
View::button(
ui,
PENCIL.to_string(),
Colors::white_or_black(false),
|| {
self.word_index_edit = num - 1;
self.word_edit = word.text.clone();
self.valid_word_edit = word.valid;
// Show word edit modal.
Modal::new(WORD_INPUT_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("wallets.recovery_phrase"))
.show();
},
);
ui.label(
RichText::new(format!("#{} {}", num, word.text))
.size(17.0)
.color(color),
);
} else {
ui.add_space(12.0);
let text = format!("#{} {}", num, word.text);
ui.label(RichText::new(text).size(17.0).color(color));
}
}
// Select list of words based on current mode and edit flag. /// Reset mnemonic phrase state to default values.
let words = match self.mnemonic.mode { pub fn reset(&mut self) {
PhraseMode::Generate => { self.mnemonic = Mnemonic::default();
if edit_words { }
&self.mnemonic.confirm_words
} else {
&self.mnemonic.words
}
}
PhraseMode::Import => &self.mnemonic.words
}.clone();
let mut word_number = 0; /// Draw word input [`Modal`] content.
let cols = list_columns_count(ui); fn word_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) {
let _ = words.chunks(cols).map(|chunk| { let on_save = |c: &mut MnemonicSetup| {
let size = chunk.len(); // Insert word checking validity.
word_number += 1; let word = &c.word_edit.trim().to_string();
if size > 1 { c.valid_word_edit = c.mnemonic.insert(c.word_index_edit, word);
ui.columns(cols, |columns| { if !c.valid_word_edit {
columns[0].horizontal(|ui| { return;
let word = chunk.get(0).unwrap(); }
self.word_item_ui(ui, word_number, word, edit_words, cb); // Close modal or go to next word to edit.
}); let next_word = c.mnemonic.get(c.word_index_edit + 1);
columns[1].horizontal(|ui| { let close_modal = next_word.is_none()
word_number += 1; || (!next_word.as_ref().unwrap().text.is_empty() && next_word.unwrap().valid);
let word = chunk.get(1).unwrap(); if close_modal {
self.word_item_ui(ui, word_number, word, edit_words, cb); Modal::close();
}); } else {
if size > 2 { c.word_index_edit += 1;
columns[2].horizontal(|ui| { c.word_edit = String::from("");
word_number += 1; }
let word = chunk.get(2).unwrap(); };
self.word_item_ui(ui, word_number, word, edit_words, cb);
});
}
if size > 3 {
columns[3].horizontal(|ui| {
word_number += 1;
let word = chunk.get(3).unwrap();
self.word_item_ui(ui, word_number, word, edit_words, cb);
});
}
});
} else {
ui.columns(cols, |columns| {
columns[0].horizontal(|ui| {
let word = chunk.get(0).unwrap();
self.word_item_ui(ui, word_number, word, edit_words, cb);
});
});
}
}).collect::<Vec<_>>();
});
ui.add_space(6.0);
}
/// Draw word list item for current mode. ui.add_space(6.0);
fn word_item_ui(&mut self, ui.vertical_centered(|ui| {
ui: &mut egui::Ui, ui.label(
num: usize, RichText::new(t!("wallets.enter_word", "number" => self.word_index_edit + 1))
word: &String, .size(17.0)
edit: bool, .color(Colors::gray()),
cb: &dyn PlatformCallbacks) { );
if edit { ui.add_space(8.0);
ui.add_space(6.0);
View::button(ui, PENCIL.to_string(), Colors::button(), || {
// Setup modal values.
self.word_num_edit = num;
self.word_edit = word.clone();
self.valid_word_edit = true;
// Show word edit modal.
Modal::new(WORD_INPUT_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("wallets.recovery_phrase"))
.show();
cb.show_keyboard();
});
ui.label(RichText::new(format!("#{} {}", num, word))
.size(17.0)
.color(Colors::white_or_black(true)));
} else {
ui.add_space(12.0);
let text = format!("#{} {}", num, word);
ui.label(RichText::new(text).size(17.0).color(Colors::white_or_black(true)));
}
}
/// Reset mnemonic phrase to default values. // Draw word value text edit.
pub fn reset(&mut self) { let mut word_edit = TextEdit::new(Id::from(modal.id).with(self.word_index_edit));
self.mnemonic = Mnemonic::default(); word_edit.ui(ui, &mut self.word_edit, cb);
} if word_edit.enter_pressed {
on_save(self);
}
/// Draw word input [`Modal`] content. // Show error when specified word is not valid.
fn word_modal_ui(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { if !self.valid_word_edit {
ui.add_space(6.0); ui.add_space(12.0);
ui.vertical_centered(|ui| { ui.label(
ui.label(RichText::new(t!("wallets.enter_word", "number" => self.word_num_edit)) RichText::new(t!("wallets.not_valid_word"))
.size(17.0) .size(17.0)
.color(Colors::gray())); .color(Colors::red()),
ui.add_space(8.0); );
}
ui.add_space(12.0);
});
// Draw word value text edit. // Show modal buttons.
let mut text_edit_opts = TextEditOptions::new( ui.scope(|ui| {
Id::from(modal.id).with(self.word_num_edit) // Setup spacing between buttons.
); ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
View::text_edit(ui, cb, &mut self.word_edit, &mut text_edit_opts);
// Show error when specified word is not valid. ui.columns(2, |columns| {
if !self.valid_word_edit { columns[0].vertical_centered_justified(|ui| {
ui.add_space(12.0); View::button(
ui.label(RichText::new(t!("wallets.not_valid_word")) ui,
.size(17.0) t!("modal.cancel"),
.color(Colors::red())); Colors::white_or_black(false),
} || {
ui.add_space(12.0); // Close modal.
}); Modal::close();
},
// Show modal buttons. );
ui.scope(|ui| { });
// Setup spacing between buttons. columns[1].vertical_centered_justified(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); // Show save button.
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
ui.columns(2, |columns| { on_save(self);
columns[0].vertical_centered_justified(|ui| { });
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { });
// Close modal. });
cb.hide_keyboard(); ui.add_space(6.0);
modal.close(); });
}); }
});
columns[1].vertical_centered_justified(|ui| {
// Callback to save the word.
let mut save = || {
self.word_edit = self.word_edit.trim().to_string();
// Check if word is valid.
let word_index = self.word_num_edit - 1;
if !self.mnemonic.is_valid_word(&self.word_edit, word_index) {
self.valid_word_edit = false;
return;
}
self.valid_word_edit = true;
// Select list where to save word.
let words = match self.mnemonic.mode {
PhraseMode::Generate => &mut self.mnemonic.confirm_words,
PhraseMode::Import => &mut self.mnemonic.words
};
// Save word at list.
words.remove(word_index);
words.insert(word_index, self.word_edit.clone());
// Close modal or go to next word to edit.
let close_modal = words.len() == self.word_num_edit
|| !words.get(self.word_num_edit).unwrap().is_empty();
if close_modal {
// Check if entered phrase was valid when all words were entered.
if !self.mnemonic.words.contains(&String::from("")) {
self.valid_phrase = self.mnemonic.is_valid_phrase();
}
cb.hide_keyboard();
modal.close();
} else {
self.word_num_edit += 1;
self.word_edit = String::from("");
}
};
// Call save on Enter key press.
View::on_enter_key(ui, || {
(save)();
});
// Show save button.
View::button(ui, t!("continue"), Colors::white_or_black(false), save);
});
});
ui.add_space(6.0);
});
}
/// Show QR code recovery phrase scanner [`Modal`].
pub fn show_qr_scan_modal(&mut self, cb: &dyn PlatformCallbacks) {
self.scan_phrase_not_found = None;
// Show QR code scan modal.
Modal::new(QR_CODE_PHRASE_SCAN_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("scan_qr"))
.closeable(false)
.show();
cb.start_camera();
}
/// Draw QR code scan [`Modal`] content.
fn scan_qr_modal_ui(&mut self,
ui: &mut egui::Ui,
modal: &Modal,
cb: &dyn PlatformCallbacks) {
// Show scan result if exists or show camera content while scanning.
if let Some(_) = &self.scan_phrase_not_found {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("wallets.rec_phrase_not_found"))
.size(17.0)
.color(Colors::red()));
});
ui.add_space(6.0);
} else if let Some(result) = self.camera_content.qr_scan_result() {
cb.stop_camera();
self.camera_content.clear_state();
match &result {
QrScanResult::Text(text) => {
self.mnemonic.import_text(text, false);
if self.mnemonic.is_valid_phrase() {
modal.close();
return;
}
}
_ => {}
}
// Set an error when found phrase was not valid.
self.scan_phrase_not_found = Some(true);
Modal::set_title(t!("scan_result"));
} else {
ui.add_space(6.0);
self.camera_content.ui(ui, cb);
ui.add_space(6.0);
}
if self.scan_phrase_not_found.is_some() {
// 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!("close"), Colors::white_or_black(false), || {
self.scan_phrase_not_found = None;
modal.close();
});
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("repeat"), Colors::white_or_black(false), || {
Modal::set_title(t!("scan_qr"));
self.scan_phrase_not_found = None;
cb.start_camera();
});
});
});
} else {
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
cb.stop_camera();
modal.close();
});
});
}
ui.add_space(6.0);
}
} }
/// Calculate word list columns count based on available ui width. /// Calculate word list columns count based on available ui width.
fn list_columns_count(ui: &mut egui::Ui) -> usize { fn list_columns_count(ui: &mut egui::Ui) -> usize {
let w = ui.available_width(); let w = ui.available_width();
let min_panel_w = Content::SIDE_PANEL_WIDTH - 12.0; let min_panel_w = Content::SIDE_PANEL_WIDTH - 12.0;
let double_min_panel_w = min_panel_w * 2.0; let double_min_panel_w = min_panel_w * 2.0;
if w >= min_panel_w * 1.5 && w < double_min_panel_w { if w >= min_panel_w * 1.5 && w < double_min_panel_w {
3 3
} else if w >= double_min_panel_w { } else if w >= double_min_panel_w {
4 4
} else { } else {
2 2
} }
} }
+3 -3
View File
@@ -15,7 +15,7 @@
mod mnemonic; mod mnemonic;
pub use mnemonic::MnemonicSetup; pub use mnemonic::MnemonicSetup;
mod creation; mod content;
pub use creation::WalletCreation; pub use content::WalletCreationContent;
pub mod types; pub mod types;
+15 -15
View File
@@ -15,21 +15,21 @@
/// Wallet creation step. /// Wallet creation step.
#[derive(PartialEq, Clone)] #[derive(PartialEq, Clone)]
pub enum Step { pub enum Step {
/// Mnemonic phrase input. /// Mnemonic phrase input.
EnterMnemonic, EnterMnemonic,
/// Mnemonic phrase confirmation. /// Mnemonic phrase confirmation.
ConfirmMnemonic, ConfirmMnemonic,
/// Wallet connection setup. /// Wallet connection setup.
SetupConnection SetupConnection,
} }
impl Step { impl Step {
/// Short name representing creation step. /// Short name representing creation step.
pub fn name(&self) -> String { pub fn name(&self) -> String {
match *self { match *self {
Step::EnterMnemonic => "enter_phrase".to_owned(), Step::EnterMnemonic => "enter_phrase".to_owned(),
Step::ConfirmMnemonic => "confirm_phrase".to_owned(), Step::ConfirmMnemonic => "confirm_phrase".to_owned(),
Step::SetupConnection => "setup_conn".to_owned(), Step::SetupConnection => "setup_conn".to_owned(),
} }
} }
} }
+2 -2
View File
@@ -12,11 +12,11 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
pub mod modals;
mod creation; mod creation;
pub mod modals;
mod content; mod content;
pub use content::*; pub use content::*;
mod wallet; mod wallet;
use wallet::*; use wallet::*;
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2024 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::{Id, RichText};
use grin_util::ZeroingString;
use crate::gui::Colors;
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, TextEdit, View};
/// Initial wallet creation [`Modal`] content.
pub struct AddWalletModal {
/// Wallet name.
pub name_edit: String,
/// Password to encrypt created wallet.
pub pass_edit: String,
}
impl Default for AddWalletModal {
fn default() -> Self {
Self {
name_edit: t!("wallets.default_wallet").into(),
pass_edit: "".to_string(),
}
}
}
impl AddWalletModal {
/// Draw creating wallet name/password input [`Modal`] content.
pub fn ui(
&mut self,
ui: &mut egui::Ui,
modal: &Modal,
cb: &dyn PlatformCallbacks,
mut on_input: impl FnMut(String, ZeroingString),
) {
let mut on_next = |m: &mut AddWalletModal| {
let name = m.name_edit.clone();
let pass = m.pass_edit.clone();
if name.is_empty() || pass.is_empty() {
return;
}
Modal::close();
on_input(name, ZeroingString::from(pass));
};
ui.vertical_centered(|ui| {
ui.add_space(6.0);
ui.label(
RichText::new(t!("wallets.name"))
.size(17.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
// Show wallet name text edit.
let mut name_input = TextEdit::new(Id::from(modal.id).with("name")).focus(false);
name_input.ui(ui, &mut self.name_edit, cb);
ui.add_space(8.0);
ui.label(
RichText::new(t!("wallets.pass"))
.size(17.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
// Show wallet password text edit.
let mut pass_input = TextEdit::new(Id::from(modal.id).with("pass"))
.password()
.focus(Modal::first_draw());
if name_input.enter_pressed {
pass_input.focus_request();
}
pass_input.ui(ui, &mut self.pass_edit, cb);
if pass_input.enter_pressed {
on_next(self);
}
ui.add_space(12.0);
});
// Show modal buttons.
ui.scope(|ui| {
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
// Close modal.
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
on_next(self);
});
});
});
ui.add_space(6.0);
});
}
}
+141
View File
@@ -0,0 +1,141 @@
// 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.
use egui::scroll_area::ScrollBarVisibility;
use egui::{Id, OpenUrl, RichText, ScrollArea};
use crate::gui::Colors;
use crate::gui::icons::{BRACKETS_CURLY, GITHUB_LOGO, TELEGRAM_LOGO};
use crate::gui::views::{Modal, View};
/// Application release changelog content.
pub struct ChangelogContent {
/// Changelog text.
changelog: String,
}
/// Endpoint for GitHub repository.
const GITHUB_URL: &'static str = "https://github.com/GetGrin/grim";
/// Endpoint for Telegram releases channel.
const TELEGRAM_URL: &'static str = "https://t.me/grim_releases";
/// Endpoint for git repository.
const GIT_URL: &'static str = "https://code.gri.mw/GUI/grim";
impl ChangelogContent {
/// Create new content instance.
pub fn new(changelog: String) -> Self {
Self { changelog }
}
/// Identifier for [`Modal`].
pub const MODAL_ID: &'static str = "release_changelog_modal";
/// Draw changelog [`Modal`] content.
pub fn ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("changelog"))
.size(16.0)
.color(Colors::gray()),
);
});
ui.add_space(6.0);
// Show changelog text.
ui.vertical_centered(|ui| {
let scroll_id = Id::from("release_changelog");
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(3.0);
ScrollArea::vertical()
.id_salt(scroll_id)
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.max_height(128.0)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(7.0);
let input_id = scroll_id.with("_input");
egui::TextEdit::multiline(&mut self.changelog)
.id(input_id)
.font(egui::TextStyle::Small)
.desired_rows(5)
.interactive(false)
.desired_width(f32::INFINITY)
.show(ui);
ui.add_space(6.0);
});
});
ui.add_space(2.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Setup spacing between buttons.
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(3, |columns| {
columns[0].vertical_centered_justified(|ui| {
// Draw button to open GitHub link.
let mut github_clicked = false;
View::button(ui, GITHUB_LOGO, Colors::white_or_black(false), || {
github_clicked = true;
});
if github_clicked {
ui.ctx().open_url(OpenUrl {
url: GITHUB_URL.into(),
new_tab: true,
});
}
});
columns[1].vertical_centered_justified(|ui| {
// Draw button to open Telegram link.
let mut tg_clicked = false;
View::button(ui, TELEGRAM_LOGO, Colors::white_or_black(false), || {
tg_clicked = true;
});
if tg_clicked {
ui.ctx().open_url(OpenUrl {
url: TELEGRAM_URL.into(),
new_tab: true,
});
}
});
columns[2].vertical_centered_justified(|ui| {
// Draw button to open repository link.
let mut git_clicked = false;
View::button(ui, BRACKETS_CURLY, Colors::white_or_black(false), || {
git_clicked = true;
});
if git_clicked {
ui.ctx().open_url(OpenUrl {
url: GIT_URL.into(),
new_tab: true,
});
}
});
});
ui.add_space(8.0);
View::horizontal_line(ui, Colors::item_stroke());
ui.add_space(8.0);
// Show button to close modal.
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("close"), Colors::white_or_black(false), || {
Modal::close();
});
});
ui.add_space(6.0);
}
}
-153
View File
@@ -1,153 +0,0 @@
// Copyright 2024 The Grim Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use egui::{RichText, ScrollArea};
use egui::scroll_area::ScrollBarVisibility;
use crate::gui::Colors;
use crate::gui::icons::{CHECK, CHECK_FAT, PLUS_CIRCLE};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::{Modal, View};
use crate::gui::views::network::ConnectionsContent;
use crate::gui::views::network::modals::ExternalConnectionModal;
use crate::wallet::{ConnectionsConfig, ExternalConnection};
/// Wallet connection [`Modal`] content.
pub struct WalletConnectionModal {
/// Current external connection.
pub ext_conn: Option<ExternalConnection>,
/// Flag to show connection creation.
show_conn_creation: bool,
/// External connection creation content.
add_ext_conn_content: ExternalConnectionModal
}
impl WalletConnectionModal {
/// Create from provided wallet connection.
pub fn new(ext_conn: Option<ExternalConnection>) -> Self {
ExternalConnection::check_ext_conn_availability(None);
Self {
ext_conn,
show_conn_creation: false,
add_ext_conn_content: ExternalConnectionModal::new(None),
}
}
/// Draw [`Modal`] content.
pub fn ui(&mut self,
ui: &mut egui::Ui,
modal: &Modal,
cb: &dyn PlatformCallbacks,
on_select: impl Fn(Option<i64>)) {
ui.add_space(4.0);
// Draw external connection creation content.
if self.show_conn_creation {
self.add_ext_conn_content.ui(ui, cb, modal, |conn| {
on_select(Some(conn.id));
});
return;
}
let ext_conn_list = ConnectionsConfig::ext_conn_list();
ScrollArea::vertical()
.max_height(if ext_conn_list.len() < 4 {
330.0
} else {
350.0
})
.id_source("integrated_node")
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.auto_shrink([true; 2])
.show(ui, |ui| {
ui.add_space(2.0);
// Show integrated node selection.
ConnectionsContent::integrated_node_item_ui(ui, |ui| {
let is_current_method = self.ext_conn.is_none();
if !is_current_method {
View::item_button(ui, View::item_rounding(0, 1, true), CHECK, None, || {
self.ext_conn = None;
on_select(None);
modal.close();
});
} else {
ui.add_space(14.0);
ui.label(RichText::new(CHECK_FAT).size(20.0).color(Colors::green()));
ui.add_space(14.0);
}
});
// Show button to add new external node connection.
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new(t!("wallets.ext_conn"))
.size(16.0)
.color(Colors::gray()));
ui.add_space(6.0);
let add_node_text = format!("{} {}", PLUS_CIRCLE, t!("wallets.add_node"));
View::button(ui, add_node_text, Colors::button(), || {
self.show_conn_creation = true;
});
});
ui.add_space(4.0);
if !ext_conn_list.is_empty() {
ui.add_space(8.0);
for (index, conn) in ext_conn_list.iter().enumerate() {
ui.horizontal_wrapped(|ui| {
// Draw external connection item.
let len = ext_conn_list.len();
ConnectionsContent::ext_conn_item_ui(ui, conn, index, len, |ui| {
// Draw button to select connection.
let is_current_method = if let Some(c) = self.ext_conn.as_ref() {
c.id == conn.id
} else {
false
};
if !is_current_method {
let button_rounding = View::item_rounding(index, len, true);
View::item_button(ui, button_rounding, CHECK, None, || {
self.ext_conn = Some(conn.clone());
on_select(Some(conn.id));
modal.close();
});
} else {
ui.add_space(12.0);
ui.label(RichText::new(CHECK_FAT)
.size(20.0)
.color(Colors::green()));
}
});
});
}
}
ui.add_space(4.0);
});
ui.add_space(2.0);
View::horizontal_line(ui, Colors::stroke());
ui.add_space(6.0);
// Show button to close modal.
ui.vertical_centered_justified(|ui| {
View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || {
modal.close();
});
});
ui.add_space(6.0);
}
}

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