diff --git a/.cargo/config.toml b/.cargo/config.toml index ae8b003..d44ae6f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,12 +1,6 @@ [build] target-dir = "build-cache" -[target.x86_64-unknown-linux-gnu] -linker = "x86_64-unknown-linux-gnu-gcc" -# You might also need to specify AR and CC explicitly, though Cargo often infers CC from the linker. -# cc = "x86_64-unknown-linux-gnu-gcc" -# ar = "x86_64-unknown-linux-gnu-ar" - [dependencies.openssl-sys] version = "0.9.109" # Use your actual version features = ["vendored"] diff --git a/.dockerignore b/.dockerignore index a76a51c..feeedb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,10 +26,12 @@ program-keypair.json **/*.so build-cache target -ui/node_modules -ui/.next -ui/out -ui/data -ui/logs -ui/src-tauri/target +apps/desktop/node_modules +apps/desktop/.next +apps/desktop/out +apps/desktop/data +apps/desktop/logs +apps/desktop/src-tauri/target +apps/mobile/.dart_tool +apps/mobile/build *.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6b9c6e..cea3948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,19 @@ jobs: patchelf \ pkg-config + - name: Set up Node.js for embedded desktop assets + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20.19.5 + cache: npm + cache-dependency-path: apps/desktop/package-lock.json + + - name: Build embedded desktop assets + working-directory: apps/desktop + run: | + npm ci + npm run build + - name: Check formatting run: cargo fmt --all -- --check @@ -53,7 +66,7 @@ jobs: timeout-minutes: 30 defaults: run: - working-directory: ui + working-directory: apps/desktop steps: - name: Check out repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -63,7 +76,7 @@ jobs: with: node-version: 20.19.5 cache: npm - cache-dependency-path: ui/package-lock.json + cache-dependency-path: apps/desktop/package-lock.json - name: Install locked dependencies run: | @@ -92,9 +105,35 @@ jobs: - name: Audit all Node dependencies run: npm audit --audit-level=high + mobile: + name: Flutter mobile release gate + runs-on: ubuntu-24.04 + timeout-minutes: 30 + defaults: + run: + working-directory: apps/mobile + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Flutter + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2 + with: + channel: stable + cache: true + + - name: Install Flutter dependencies + run: flutter pub get + + - name: Analyze mobile app + run: flutter analyze + + - name: Test mobile app + run: flutter test + container: name: Linux container build - needs: [rust, frontend] + needs: [rust, frontend, mobile] runs-on: ubuntu-24.04 timeout-minutes: 90 steps: @@ -102,4 +141,4 @@ jobs: uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Build deployment image - run: docker build --pull --tag sol-safekey-ui:ci . + run: docker build --pull --tag fnzero-safe:ci . diff --git a/.gitignore b/.gitignore index 4671d22..94af2e8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,10 +13,11 @@ target # Generated by cargo mutants # Contains mutation testing data **/mutants.out*/ -sol-safekey +fnzero-safe open-source build-cache keystore.json +/release/ # Local configuration and deployment signing material .env @@ -26,20 +27,37 @@ keystore.json *-keypair.json program-keypair.json -# Integrated web and desktop UI -/ui/node_modules/ +# Desktop UI /ui/.next/ -/ui/.next-build/ -/ui/out/ -/ui/coverage/ -/ui/data/ -/ui/logs/ -/ui/tmp/ -/ui/.env* -/ui/.vercel/ -/ui/next-env.d.ts -/ui/*.tsbuildinfo -/ui/*.pid +/apps/desktop/node_modules/ +/apps/desktop/.next/ +/apps/desktop/.next-build/ +/apps/desktop/out/ +/apps/desktop/coverage/ +/apps/desktop/data/ +/apps/desktop/logs/ +/apps/desktop/tmp/ +/apps/desktop/.env* +/apps/desktop/.vercel/ +/apps/desktop/next-env.d.ts +/apps/desktop/*.tsbuildinfo +/apps/desktop/*.pid + +# Flutter mobile UI +/apps/mobile/.dart_tool/ +/apps/mobile/.flutter-plugins +/apps/mobile/.flutter-plugins-dependencies +/apps/mobile/build/ +/apps/mobile/ios/Flutter/Generated.xcconfig +/apps/mobile/ios/Flutter/flutter_export_environment.sh +/apps/mobile/ios/Frameworks/ +/apps/mobile/ios/Pods/ +/apps/mobile/ios/.symlinks/ +/apps/mobile/ios/Runner.xcworkspace/xcuserdata/ +/apps/mobile/ios/Runner.xcodeproj/xcuserdata/ +/apps/mobile/android/.gradle/ +/apps/mobile/android/app/.cxx/ +/apps/mobile/android/app/src/main/jniLibs/ # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can diff --git a/BOT_INTEGRATION.md b/BOT_INTEGRATION.md index ff8ce98..6e2cbf3 100644 --- a/BOT_INTEGRATION.md +++ b/BOT_INTEGRATION.md @@ -1,12 +1,12 @@ # Bot Integration Guide -Complete guide for integrating sol-safekey into your Solana trading bot or application. +Complete guide for integrating fnzero-safe into your Solana trading bot or application. [中文文档](BOT_INTEGRATION_CN.md) -## Why Sol-SafeKey? +## Why FnzeroSafe? -Sol-SafeKey provides military-grade wallet security with simple integration - just 3 lines of code to add a complete interactive wallet management system to your bot. +FnzeroSafe provides military-grade wallet security with simple integration - just 3 lines of code to add a complete interactive wallet management system to your bot. ### Key Benefits @@ -24,11 +24,11 @@ Add to your `Cargo.toml`: ```toml [dependencies] -sol-safekey = { path = "../sol-safekey" } +fnzero-safe = { path = "../fnzero-safe" } [features] default = ["solana-ops"] -solana-ops = ["sol-safekey/solana-ops"] +solana-ops = ["fnzero-safe/solana-ops"] ``` ### Step 2: Add Safekey Command @@ -42,8 +42,8 @@ fn main() -> Result<()> { // Check if running in safekey interactive mode let args: Vec = std::env::args().skip(1).collect(); if args.first().map(|s| s.as_str()) == Some("safekey") { - // Launch sol-safekey interactive menu - if let Err(e) = sol_safekey::interactive::show_main_menu() { + // Launch fnzero-safe interactive menu + if let Err(e) = fnzero_safe::interactive::show_main_menu() { eprintln!("❌ {}", e); std::process::exit(1); } @@ -97,7 +97,7 @@ This launches the full interactive menu with all wallet operations: ### Password Handling -Sol-SafeKey uses a secure password handling model: +FnzeroSafe uses a secure password handling model: **✅ Secure Approach:** - Password passed via stdin pipe @@ -156,7 +156,7 @@ fi ### Loading Encrypted Wallet ```rust -use sol_safekey::KeyManager; +use fnzero_safe::KeyManager; use std::io::{self, Read}; fn load_wallet() -> Result { @@ -180,7 +180,7 @@ fn load_wallet() -> Result { ### Creating New Wallet ```rust -use sol_safekey::KeyManager; +use fnzero_safe::KeyManager; fn create_wallet(password: &str) -> Result<()> { // Generate new keypair @@ -201,7 +201,7 @@ fn create_wallet(password: &str) -> Result<()> { ### Using Solana Operations ```rust -use sol_safekey::solana_ops::SolanaClient; +use fnzero_safe::solana_ops::SolanaClient; fn bot_logic(keypair: &solana_sdk::signature::Keypair) -> Result<()> { // Initialize Solana client @@ -252,7 +252,7 @@ echo "your-password" | ./build-cache/release/examples/bot_example ## Key Features Summary -Sol-SafeKey provides comprehensive wallet management: +FnzeroSafe provides comprehensive wallet management: | Feature | Support | |---------|---------| @@ -290,7 +290,7 @@ solana airdrop 2 YOUR_WALLET_ADDRESS --url devnet ### Integration Checklist -- [ ] Added sol-safekey dependency to Cargo.toml +- [ ] Added fnzero-safe dependency to Cargo.toml - [ ] Added 3-line safekey command check to main() - [ ] Created secure startup script with stdin password - [ ] Tested wallet creation with safekey command diff --git a/BOT_INTEGRATION_CN.md b/BOT_INTEGRATION_CN.md index d69e7ae..fc1f4ba 100644 --- a/BOT_INTEGRATION_CN.md +++ b/BOT_INTEGRATION_CN.md @@ -1,12 +1,12 @@ # Bot 集成指南 -将 sol-safekey 集成到你的 Solana 交易机器人或应用程序的完整指南。 +将 fnzero-safe 集成到你的 Solana 交易机器人或应用程序的完整指南。 [English Documentation](BOT_INTEGRATION.md) -## 为什么选择 Sol-SafeKey? +## 为什么选择 FnzeroSafe? -Sol-SafeKey 提供军事级钱包安全性和简单集成 - 只需 3 行代码即可为你的 bot 添加完整的交互式钱包管理系统。 +FnzeroSafe 提供军事级钱包安全性和简单集成 - 只需 3 行代码即可为你的 bot 添加完整的交互式钱包管理系统。 ### 核心优势 @@ -24,11 +24,11 @@ Sol-SafeKey 提供军事级钱包安全性和简单集成 - 只需 3 行代码 ```toml [dependencies] -sol-safekey = { path = "../sol-safekey" } +fnzero-safe = { path = "../fnzero-safe" } [features] default = ["solana-ops"] -solana-ops = ["sol-safekey/solana-ops"] +solana-ops = ["fnzero-safe/solana-ops"] ``` ### 步骤 2: 添加 Safekey 命令 @@ -42,8 +42,8 @@ fn main() -> Result<()> { // 检查是否运行在 safekey 交互模式 let args: Vec = std::env::args().skip(1).collect(); if args.first().map(|s| s.as_str()) == Some("safekey") { - // 启动 sol-safekey 交互式菜单 - if let Err(e) = sol_safekey::interactive::show_main_menu() { + // 启动 fnzero-safe 交互式菜单 + if let Err(e) = fnzero_safe::interactive::show_main_menu() { eprintln!("❌ {}", e); std::process::exit(1); } @@ -97,7 +97,7 @@ cargo build --features solana-ops --release ### 密码处理 -Sol-SafeKey 使用安全的密码处理模型: +FnzeroSafe 使用安全的密码处理模型: **✅ 安全方式:** - 密码通过 stdin 管道传递 @@ -156,7 +156,7 @@ fi ### 加载加密钱包 ```rust -use sol_safekey::KeyManager; +use fnzero_safe::KeyManager; use std::io::{self, Read}; fn load_wallet() -> Result { @@ -180,7 +180,7 @@ fn load_wallet() -> Result { ### 创建新钱包 ```rust -use sol_safekey::KeyManager; +use fnzero_safe::KeyManager; fn create_wallet(password: &str) -> Result<()> { // 生成新密钥对 @@ -201,7 +201,7 @@ fn create_wallet(password: &str) -> Result<()> { ### 使用 Solana 操作 ```rust -use sol_safekey::solana_ops::SolanaClient; +use fnzero_safe::solana_ops::SolanaClient; fn bot_logic(keypair: &solana_sdk::signature::Keypair) -> Result<()> { // 初始化 Solana 客户端 @@ -252,7 +252,7 @@ echo "你的密码" | ./build-cache/release/examples/bot_example ## 核心功能总结 -Sol-SafeKey 提供全面的钱包管理: +FnzeroSafe 提供全面的钱包管理: | 功能 | 支持 | |------|------| @@ -290,7 +290,7 @@ solana airdrop 2 你的钱包地址 --url devnet ### 集成检查清单 -- [ ] 在 Cargo.toml 中添加了 sol-safekey 依赖 +- [ ] 在 Cargo.toml 中添加了 fnzero-safe 依赖 - [ ] 在 main() 中添加了 3 行 safekey 命令检查 - [ ] 创建了带 stdin 密码的安全启动脚本 - [ ] 通过 safekey 命令测试了钱包创建 diff --git a/Cargo.lock b/Cargo.lock index 819b1cc..a6b6179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,15 @@ dependencies = [ "regex", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -76,7 +85,7 @@ version = "3.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6200f3b8cfbe5992fde00d443f60e62a79d2d8f6a658af1ffb7c4f0baa3c7028" dependencies = [ - "ahash 0.8.12", + "ahash", "solana-epoch-schedule 3.0.0", "solana-hash 3.1.0", "solana-pubkey 3.0.0", @@ -155,17 +164,6 @@ dependencies = [ "solana-transaction-context", ] -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.16", - "once_cell", - "version_check", -] - [[package]] name = "ahash" version = "0.8.12" @@ -188,6 +186,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "allo-isolate" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -334,26 +343,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "app" -version = "0.4.0" -dependencies = [ - "aes-gcm", - "aws-lc-rs", - "base64 0.22.1", - "log", - "pem-rfc7468", - "rand 0.8.6", - "reqwest 0.12.28", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-log", - "zeroize", -] - [[package]] name = "arc-swap" version = "1.7.1" @@ -418,7 +407,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" dependencies = [ - "ahash 0.8.12", + "ahash", "ark-ff 0.5.0", "ark-poly 0.5.0", "ark-serialize 0.5.0", @@ -538,7 +527,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" dependencies = [ - "ahash 0.8.12", + "ahash", "ark-ff 0.5.0", "ark-serialize 0.5.0", "ark-std 0.5.0", @@ -699,7 +688,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -790,7 +779,7 @@ version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "event-listener-strategy", "pin-project-lite", ] @@ -819,7 +808,7 @@ dependencies = [ "async-task", "blocking", "cfg-if", - "event-listener 5.4.1", + "event-listener 5.4.2", "futures-lite 2.6.1", "rustix", ] @@ -915,6 +904,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1049,6 +1044,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.0", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -1100,6 +1110,19 @@ dependencies = [ "serde", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -1115,6 +1138,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1130,18 +1162,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -1322,6 +1342,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + [[package]] name = "bumpalo" version = "3.19.0" @@ -1338,40 +1364,6 @@ dependencies = [ "serde", ] -[[package]] -name = "byte-unit" -version = "5.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0" -dependencies = [ - "rust_decimal", - "schemars 1.2.1", - "serde", - "utf8-width", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "bytemuck" version = "1.24.0" @@ -2105,6 +2097,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dart-sys" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895" +dependencies = [ + "cc", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -2138,6 +2139,17 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "delegate-attr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84c9a9c129b98e707ac9a31e204c10c064dd9f949b7da362310ff1a8b137d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "der" version = "0.7.10" @@ -2657,11 +2669,10 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2672,7 +2683,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "pin-project-lite", ] @@ -2832,12 +2843,196 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flutter_rust_bridge" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0884853aae8a6517b5b58cf36f55da487f2fe110e1686938eb29b6640aae4a5" +dependencies = [ + "allo-isolate", + "android_logger", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5ce32f35f710ced8c5aa557f023f1a624e737b5460cee2b70fcd3a8df09e1b" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fnzero-safe-app-services" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "bincode", + "bip39", + "borsh 1.7.0", + "fnzero-safe-core", + "serde", + "serde_json", + "sha2 0.10.9", + "solana-account-decoder-client-types", + "solana-client", + "solana-commitment-config", + "solana-derivation-path", + "solana-rpc-client-types", + "solana-sdk", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "fnzero-safe-core" +version = "0.1.8" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "argon2", + "base64 0.22.1", + "bincode", + "bs58", + "chrono", + "clap", + "colored", + "data-encoding", + "dotenv", + "ghash", + "hex", + "keccak", + "openssl", + "qrcode", + "rand 0.8.6", + "ring", + "rpassword", + "serde", + "serde_json", + "sol-trade-sdk", + "solana-client", + "solana-commitment-config", + "solana-message 3.1.0", + "solana-sdk", + "tokio", + "totp-rs", + "zeroize", +] + +[[package]] +name = "fnzero-safe-desktop" +version = "0.4.0" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "base64 0.22.1", + "block2 0.6.2", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-local-authentication", + "pem-rfc7468", + "rand 0.8.6", + "reqwest 0.12.28", + "rfd", + "security-framework 3.5.1", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-log", + "zeroize", +] + +[[package]] +name = "fnzero-safe-desktop-api" +version = "0.4.0" +dependencies = [ + "aes-gcm", + "agave-syscalls", + "anyhow", + "aws-lc-rs", + "axum 0.8.9", + "base64 0.22.1", + "bincode", + "bip39", + "borsh 1.7.0", + "bs58", + "fnzero-safe-app-services", + "fnzero-safe-core", + "futures", + "mime_guess", + "pem-rfc7468", + "rand 0.8.6", + "reqwest 0.12.28", + "rusqlite", + "rust-embed", + "serde", + "serde_json", + "sha2 0.10.9", + "sol-trade-sdk", + "solana-account-decoder-client-types", + "solana-client", + "solana-commitment-config", + "solana-derivation-path", + "solana-loader-v3-interface 6.1.0", + "solana-program-runtime", + "solana-rpc-client-api", + "solana-sbpf", + "solana-sdk", + "solana-svm-feature-set", + "solana-transaction-status-client-types", + "spl-token", + "tokio", + "tower 0.5.2", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", + "zeroize", +] + +[[package]] +name = "fnzero-safe-mobile-bridge" +version = "0.1.0" +dependencies = [ + "flutter_rust_bridge", + "fnzero-safe-app-services", + "fnzero-safe-core", + "serde", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -2907,12 +3102,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futf" version = "0.1.5" @@ -3235,6 +3424,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -3416,9 +3611,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -3447,9 +3642,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] [[package]] name = "hashbrown" @@ -3457,7 +3649,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" dependencies = [ - "ahash 0.8.12", + "ahash", ] [[package]] @@ -3509,6 +3701,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "histogram" version = "0.6.9" @@ -4357,9 +4558,6 @@ name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -dependencies = [ - "value-bag", -] [[package]] name = "lru-slab" @@ -4436,6 +4634,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.7.5" @@ -4888,10 +5096,23 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.9.4", "block2 0.6.2", + "libc", "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-local-authentication" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48e0b8b339e0d9d2ed4416b7f93f9d4daadff7d4dd797f89867cde11aeac607" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-security", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -4917,6 +5138,17 @@ dependencies = [ "objc2-metal", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -4943,6 +5175,15 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.6.1" @@ -5039,6 +5280,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap 5.5.3", + "log", +] + [[package]] name = "pango" version = "0.18.3" @@ -5656,26 +5908,6 @@ dependencies = [ "prost 0.13.5", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "pxfm" version = "0.1.24" @@ -5814,12 +6046,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.7.3" @@ -6047,15 +6273,6 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -6200,35 +6417,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rpassword" version = "7.4.0" @@ -6299,23 +6487,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "rust_decimal" -version = "1.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" -dependencies = [ - "arrayvec", - "borsh 1.7.0", - "bytes", - "num-traits", - "rand 0.8.6", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "rustc-demangle" version = "0.1.26" @@ -6529,12 +6700,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sec1" version = "0.7.3" @@ -6944,12 +7109,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "siphasher" version = "0.3.11" @@ -7027,87 +7186,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "sol-safekey" -version = "0.1.8" -dependencies = [ - "aes", - "aes-gcm", - "anyhow", - "argon2", - "base64 0.22.1", - "bincode", - "bs58", - "chrono", - "clap", - "colored", - "data-encoding", - "dotenv", - "ghash", - "hex", - "keccak", - "openssl", - "qrcode", - "rand 0.8.6", - "ring", - "rpassword", - "serde", - "serde_json", - "sol-trade-sdk", - "solana-client", - "solana-commitment-config", - "solana-message 3.1.0", - "solana-sdk", - "tokio", - "totp-rs", - "zeroize", -] - -[[package]] -name = "sol-safekey-ui" -version = "0.4.0" -dependencies = [ - "aes-gcm", - "agave-syscalls", - "anyhow", - "aws-lc-rs", - "axum 0.8.9", - "base64 0.22.1", - "bincode", - "borsh 1.7.0", - "bs58", - "futures", - "mime_guess", - "pem-rfc7468", - "reqwest 0.12.28", - "rusqlite", - "rust-embed", - "serde", - "serde_json", - "sha2 0.10.9", - "sol-safekey", - "sol-trade-sdk", - "solana-account-decoder-client-types", - "solana-client", - "solana-commitment-config", - "solana-loader-v3-interface 6.1.0", - "solana-program-runtime", - "solana-rpc-client-api", - "solana-sbpf", - "solana-sdk", - "solana-svm-feature-set", - "solana-transaction-status-client-types", - "spl-token", - "tokio", - "tower 0.5.2", - "tower-http", - "tracing", - "tracing-subscriber", - "urlencoding", - "uuid", - "zeroize", -] - [[package]] name = "sol-trade-sdk" version = "4.0.21" @@ -7523,7 +7601,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cd4d41f391f427e64e03fb00fb0c93443f18464dafd71e06f996ac03a3982" dependencies = [ "agave-feature-set", - "ahash 0.8.12", + "ahash", "log", "solana-bpf-loader-program", "solana-compute-budget-program", @@ -8572,7 +8650,7 @@ version = "3.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6efdfcbe201b25d6c30a69bb27201867d3da3c945dd06b181e61d5b956280663" dependencies = [ - "ahash 0.8.12", + "ahash", "bincode", "bv", "bytes", @@ -10708,12 +10786,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "target-lexicon" version = "0.12.16" @@ -10853,12 +10925,11 @@ dependencies = [ [[package]] name = "tauri-plugin-log" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" dependencies = [ "android_logger", - "byte-unit", "fern", "log", "objc2 0.6.4", @@ -11055,6 +11126,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "time" version = "0.3.53" @@ -11672,6 +11752,15 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -11767,12 +11856,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-width" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -11803,12 +11886,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "value-bag" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd4ec1eb1d240636e354a30110a1dfcb37047169a4d9bd6d9d3469df574b5c4" - [[package]] name = "vcpkg" version = "0.2.15" @@ -12811,15 +12888,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" @@ -12908,7 +12976,7 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener 5.4.1", + "event-listener 5.4.2", "futures-core", "futures-lite 2.6.1", "hex", diff --git a/Cargo.toml b/Cargo.toml old mode 100755 new mode 100644 index d43319c..54fc1b6 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,82 +1,18 @@ -[package] -name = "sol-safekey" -version = "0.1.8" -edition = "2021" -rust-version = "1.89" -default-run = "sol-safekey" -description = "Secure Solana wallet key management library and CLI with encrypted keystores, bot integration, and optional 2FA" -license = "MIT" -repository = "https://github.com/0xfnzero/sol-safekey" -documentation = "https://docs.rs/sol-safekey" -homepage = "https://github.com/0xfnzero/sol-safekey" -readme = "README.md" -keywords = ["solana", "wallet", "keystore", "encryption", "2fa"] -categories = ["cryptography", "command-line-utilities"] -exclude = ["ui/**", "Dockerfile", "Makefile", ".dockerignore"] - [workspace] -members = ["ui", "ui/src-tauri"] +members = [ + "crates/core", + "crates/app-services", + "crates/desktop-api", + "crates/mobile-bridge", + "apps/desktop/src-tauri", +] resolver = "2" -# Core dependencies (always required) -[dependencies] -# Transitive via solana-sdk → sha3; keep the patched implementation pinned. -keccak = { version = "=0.1.6" } -aes = { version = "0.8", features = ["zeroize"] } -aes-gcm = { version = "0.10", features = ["zeroize"] } -argon2 = { version = "0.5", features = ["zeroize"] } -base64 = "0.22.1" -bs58 = "0.5" -ring = "0.17.8" -solana-sdk = "3.0.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -chrono = { version = "0.4", features = ["serde"] } -data-encoding = "2.4" -anyhow = "1.0" -hex = "0.4" -ghash = { version = "0.5", features = ["zeroize"] } -zeroize = "1" - -# Interactive UI dependencies (needed for bot integration) -colored = "3.0.0" -rpassword = "7.0" - -# Optional: CLI-only dependencies -clap = { version = "4.5", features = ["derive"], optional = true } -qrcode = { version = "0.14", optional = true } - -# Optional: 2FA/TOTP features -totp-rs = { version = "5.4", optional = true } -rand = { version = "0.8", optional = true } - -# Optional: Solana operations -solana-client = { version = "3.1.12", optional = true } -# Align with sol-trade-sdk (solana-message 3.1.x); avoids resolver conflict with solana-client's transitive 3.0.1. -solana-message = { version = "3.1.0", optional = true } -tokio = { version = "1.0", features = ["full"], optional = true } -bincode = { version = "1.3", optional = true } -solana-commitment-config = { version = "3.1.1", optional = true } -# Keep the core crate and integrated UI on the same SDK API. -sol-trade-sdk = { version = "=4.0.21", optional = true } - -# Optional: Other utilities -dotenv = { version = "0.15", optional = true } -openssl = { version = "0.10.80", features = ["vendored"], optional = true } - -[features] -default = [] -cli = ["clap", "qrcode", "totp-rs", "rand"] -2fa = ["totp-rs", "rand", "qrcode"] -sol-trade-sdk = ["dep:sol-trade-sdk", "tokio", "solana-commitment-config", "solana-client", "dep:solana-message"] -solana-ops = ["solana-client", "dep:solana-message", "tokio", "bincode", "sol-trade-sdk", "solana-commitment-config"] -full = ["cli", "2fa", "solana-ops", "sol-trade-sdk", "dotenv", "openssl"] - -[lib] -name = "sol_safekey" -path = "src/lib.rs" +[workspace.package] +edition = "2021" +license = "MIT" +repository = "https://github.com/0xfnzero/fnzero-safe" -[[bin]] -name = "sol-safekey" -path = "src/main.rs" -required-features = ["full"] +[workspace.dependencies] +fnzero-safe-core = { path = "crates/core", version = "0.1.8" } +fnzero-safe-app-services = { path = "crates/app-services", version = "0.1.0" } diff --git a/Dockerfile b/Dockerfile index 82e6766..fd29f00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ FROM node:20.19.5-bookworm-slim AS frontend -WORKDIR /app/ui +WORKDIR /app/apps/desktop -COPY ui/package.json ui/package-lock.json ./ -COPY ui/vendor/brace-expansion-compat ./vendor/brace-expansion-compat +COPY apps/desktop/package.json apps/desktop/package-lock.json ./ +COPY apps/desktop/vendor/brace-expansion-compat ./vendor/brace-expansion-compat RUN npm ci -COPY ui/ ./ +COPY apps/desktop/ ./ RUN npm run build FROM rust:1.89.0-bookworm AS builder @@ -18,8 +18,8 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY . . -COPY --from=frontend /app/ui/out /app/ui/out -RUN cargo build --locked --release -p sol-safekey-ui +COPY --from=frontend /app/apps/desktop/out /app/apps/desktop/out +RUN cargo build --locked --release -p fnzero-safe-desktop-api FROM debian:bookworm-slim AS runtime @@ -28,17 +28,17 @@ WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* \ - && groupadd --gid 10001 sol-safekey \ - && useradd --uid 10001 --gid 10001 --home-dir /app --no-create-home --no-log-init --shell /usr/sbin/nologin sol-safekey \ + && groupadd --gid 10001 fnzero-safe \ + && useradd --uid 10001 --gid 10001 --home-dir /app --no-create-home --no-log-init --shell /usr/sbin/nologin fnzero-safe \ && install -d -o 10001 -g 10001 /app/data -COPY --from=builder /app/build-cache/release/sol-safekey-ui /usr/local/bin/sol-safekey-ui +COPY --from=builder /app/build-cache/release/fnzero-safe-desktop-api /usr/local/bin/fnzero-safe-desktop-api # The wallet API is intentionally local-only. Run with host networking when a # container is required; publishing this loopback port with -p will not work. ENV HOST=127.0.0.1 ENV PORT=3841 -ENV SOL_SAFEKEY_DB_PATH=/app/data/sol-safekey.sqlite3 +ENV FNZERO_SAFE_DB_PATH=/app/data/fnzero-safe.sqlite3 VOLUME ["/app/data"] @@ -47,4 +47,4 @@ USER 10001:10001 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl --fail --silent --show-error http://127.0.0.1:3841/api/health >/dev/null || exit 1 -CMD ["sol-safekey-ui"] +CMD ["fnzero-safe-desktop-api"] diff --git a/INTERACTIVE_TUTORIAL.md b/INTERACTIVE_TUTORIAL.md index b7f9ad6..af5cb10 100644 --- a/INTERACTIVE_TUTORIAL.md +++ b/INTERACTIVE_TUTORIAL.md @@ -1,5 +1,5 @@
-

📖 Sol-SafeKey 交互式菜单完整教程

+

📖 FnzeroSafe 交互式菜单完整教程

Step-by-step guide for all interactive menu operations

@@ -69,7 +69,7 @@ Quick access to all 18 interactive menu operations: #### Step 1: Launch Interactive Menu ```bash -sol-safekey start +fnzero-safe start ``` You will see the language selection screen. Choose your preferred language: diff --git a/INTERACTIVE_TUTORIAL_CN.md b/INTERACTIVE_TUTORIAL_CN.md index 44f17cc..4d9d0ec 100644 --- a/INTERACTIVE_TUTORIAL_CN.md +++ b/INTERACTIVE_TUTORIAL_CN.md @@ -1,5 +1,5 @@
-

📖 Sol-SafeKey 交互式菜单完整教程

+

📖 FnzeroSafe 交互式菜单完整教程

所有交互式菜单操作的逐步指南

@@ -69,7 +69,7 @@ #### 步骤 1:启动交互式菜单 ```bash -sol-safekey start +fnzero-safe start ``` 你将看到语言选择界面。选择你偏好的语言: diff --git a/Makefile b/Makefile index 936b10e..ba3391d 100644 --- a/Makefile +++ b/Makefile @@ -1,66 +1,118 @@ SHELL := /bin/bash -RELEASE_NODE_VERSION := 20.19.5 -RELEASE_NPM_VERSION := 10.8.2 -RELEASE_CARGO_AUDIT_VERSION := 0.22.1 - .DEFAULT_GOAL := help -.PHONY: help install ui-dev ui-build api-build desktop-dev desktop-build check release-check +ROOT_DIR := $(CURDIR) +RELEASE_DIR := $(ROOT_DIR)/release +MOBILE_DIR := $(ROOT_DIR)/apps/mobile +DESKTOP_DIR := $(ROOT_DIR)/apps/desktop +ANDROID_RELEASE_DIR := $(RELEASE_DIR)/android +IOS_RELEASE_DIR := $(RELEASE_DIR)/ios +MACOS_RELEASE_DIR := $(RELEASE_DIR)/macos +WINDOWS_RELEASE_DIR := $(RELEASE_DIR)/windows +IOS_CODESIGN ?= false +IOS_EXPORT_OPTIONS_PLIST ?= +TAURI_WINDOWS_TARGET ?= x86_64-pc-windows-msvc +ANDROID_JAVA_HOME ?= $(shell if [[ -d /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ]]; then echo /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home; fi) +ANDROID_ENV := $(if $(ANDROID_JAVA_HOME),JAVA_HOME="$(ANDROID_JAVA_HOME)" PATH="$(ANDROID_JAVA_HOME)/bin:$$PATH",) -help: - @echo "Sol SafeKey workspace commands" - @echo " make install Install UI dependencies and prefetch Rust crates" - @echo " make ui-dev Start Next.js and the local Rust API" - @echo " make ui-build Build the static web UI" - @echo " make api-build Build the UI API release binary" - @echo " make desktop-dev Start the Tauri desktop app" - @echo " make desktop-build Build the Tauri desktop app" - @echo " make check Run Rust, lint, and TypeScript checks" - @echo " make release-check Run the complete non-deploying release gate" +.PHONY: help dev package package-android package-ios package-macos package-windows prepare-release-dir -install: - cd ui && npm ci - cargo fetch +help: + @echo "FnzeroSafe commands" + @echo " make dev Start the desktop dev app; old local dev processes are stopped first" + @echo " make package Build Android, iOS, macOS, and Windows packages into ./release" + @echo " make package-android Build Android APK/AAB into ./release/android" + @echo " make package-ios Build iOS app/IPA into ./release/ios" + @echo " make package-macos Build the macOS desktop package into ./release/macos" + @echo " make package-windows Build the Windows desktop package into ./release/windows" + @echo "" + @echo "Options:" + @echo " ANDROID_JAVA_HOME=/path/to/jdk17 make package-android" + @echo " IOS_CODESIGN=true IOS_EXPORT_OPTIONS_PLIST=/path/ExportOptions.plist make package-ios" + @echo " TAURI_WINDOWS_TARGET=x86_64-pc-windows-msvc make package-windows" -ui-dev: - cd ui && npm run dev:stack +dev: + cd apps/desktop && npm run desktop:dev -ui-build: - cd ui && npm run build +package: package-android package-ios package-macos package-windows -api-build: ui-build - cargo build --release -p sol-safekey-ui +prepare-release-dir: + mkdir -p "$(RELEASE_DIR)" -desktop-dev: - cd ui && npm run desktop:dev +package-android: prepare-release-dir + mkdir -p "$(ANDROID_RELEASE_DIR)" + cd "$(MOBILE_DIR)" && ./tool/build_android_native.sh + cd "$(MOBILE_DIR)" && $(ANDROID_ENV) flutter build apk --release + cd "$(MOBILE_DIR)" && $(ANDROID_ENV) flutter build appbundle + @copied=0; \ + for artifact in \ + "$(MOBILE_DIR)/build/app/outputs/flutter-apk/app-release.apk" \ + "$(MOBILE_DIR)/build/app/outputs/bundle/release/app-release.aab"; do \ + if [[ -f "$$artifact" ]]; then \ + cp -f "$$artifact" "$(ANDROID_RELEASE_DIR)/"; \ + echo "Copied $$artifact -> $(ANDROID_RELEASE_DIR)/"; \ + copied=1; \ + fi; \ + done; \ + if [[ "$$copied" != "1" ]]; then \ + echo "No Android package artifacts were found." >&2; \ + exit 1; \ + fi -desktop-build: - cd ui && npm run desktop:build +package-ios: prepare-release-dir + mkdir -p "$(IOS_RELEASE_DIR)" + cd "$(MOBILE_DIR)" && ./tool/build_ios_native.sh + @if [[ "$(IOS_CODESIGN)" == "true" ]]; then \ + if [[ -n "$(IOS_EXPORT_OPTIONS_PLIST)" ]]; then \ + cd "$(MOBILE_DIR)" && flutter build ipa --release --export-options-plist "$(IOS_EXPORT_OPTIONS_PLIST)"; \ + else \ + cd "$(MOBILE_DIR)" && flutter build ipa --release; \ + fi; \ + else \ + cd "$(MOBILE_DIR)" && flutter build ios --release --no-codesign; \ + fi + @copied=0; \ + while IFS= read -r artifact; do \ + cp -R "$$artifact" "$(IOS_RELEASE_DIR)/"; \ + echo "Copied $$artifact -> $(IOS_RELEASE_DIR)/"; \ + copied=1; \ + done < <(find "$(MOBILE_DIR)/build/ios" -maxdepth 5 \( -name "*.ipa" -o -name "*.app" \) -print 2>/dev/null); \ + if [[ "$$copied" != "1" ]]; then \ + echo "No iOS package artifacts were found." >&2; \ + exit 1; \ + fi -check: - cargo check --workspace - cd ui && npm run test:dependency-compat - cd ui && npm run test:devnet-airdrop - cd ui && npm run test:program-deploy - cd ui && npm run lint - cd ui && npm exec tsc -- --noEmit +package-macos: + mkdir -p "$(MACOS_RELEASE_DIR)" + cd "$(DESKTOP_DIR)" && npm run desktop:build + @copied=0; \ + while IFS= read -r artifact; do \ + cp -R "$$artifact" "$(MACOS_RELEASE_DIR)/"; \ + echo "Copied $$artifact -> $(MACOS_RELEASE_DIR)/"; \ + copied=1; \ + done < <(find \ + "$(DESKTOP_DIR)/src-tauri/target/release/bundle" \ + "$(ROOT_DIR)/build-cache/release/bundle" \ + -maxdepth 5 \( -name "*.dmg" -o -name "*.app" \) ! -name "rw.*" -print 2>/dev/null); \ + if [[ "$$copied" != "1" ]]; then \ + echo "No macOS desktop package artifacts were found." >&2; \ + exit 1; \ + fi -release-check: - @command -v cargo-audit >/dev/null || { echo "cargo-audit $(RELEASE_CARGO_AUDIT_VERSION) is required: cargo install cargo-audit --locked --version $(RELEASE_CARGO_AUDIT_VERSION)" >&2; exit 1; } - @test "$$(cargo audit --version | awk '{print $$NF}')" = "$(RELEASE_CARGO_AUDIT_VERSION)" || { echo "cargo-audit must be exactly $(RELEASE_CARGO_AUDIT_VERSION)" >&2; exit 1; } - @test "$$(node -p 'process.versions.node')" = "$(RELEASE_NODE_VERSION)" || { echo "Node.js must be exactly $(RELEASE_NODE_VERSION)" >&2; exit 1; } - @test "$$(npm --version)" = "$(RELEASE_NPM_VERSION)" || { echo "npm must be exactly $(RELEASE_NPM_VERSION)" >&2; exit 1; } - cd ui && node scripts/assert-no-next-dev.cjs - cargo fmt --all -- --check - cargo test --locked --workspace - cargo clippy --locked --workspace --all-targets --all-features -- -D warnings - cd ui && npm ci - cd ui && npm run test:dependency-compat - cd ui && npm run test:devnet-airdrop - cd ui && npm run test:program-deploy - cd ui && npm run lint - cd ui && npm exec tsc -- --noEmit - cd ui && npm run build - cargo audit - cd ui && npm audit --audit-level=high +package-windows: + mkdir -p "$(WINDOWS_RELEASE_DIR)" + cd "$(DESKTOP_DIR)" && npm run tauri -- build --target "$(TAURI_WINDOWS_TARGET)" + @copied=0; \ + while IFS= read -r artifact; do \ + cp -f "$$artifact" "$(WINDOWS_RELEASE_DIR)/"; \ + echo "Copied $$artifact -> $(WINDOWS_RELEASE_DIR)/"; \ + copied=1; \ + done < <(find \ + "$(DESKTOP_DIR)/src-tauri/target/$(TAURI_WINDOWS_TARGET)/release/bundle" \ + "$(ROOT_DIR)/build-cache/$(TAURI_WINDOWS_TARGET)/release/bundle" \ + -maxdepth 6 \( -name "*.msi" -o -name "*.exe" \) -print 2>/dev/null); \ + if [[ "$$copied" != "1" ]]; then \ + echo "No Windows desktop package artifacts were found." >&2; \ + exit 1; \ + fi diff --git a/README.md b/README.md index 013e8f1..54242f3 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,38 @@
-

🔐 Sol SafeKey - Secure Solana Wallet & Keystore

-

Rust SDK · CLI · Web UI · Tauri Desktop Wallet

+

FnzeroSafe

+

Local-first Solana wallet, secure keystore, desktop app, and iOS/Android app

- Open-source, local-first Solana wallet security toolkit with encrypted keystores, 2FA, bot integration, token operations, Pump trading, and Squads multisig. + FnzeroSafe is an open-source Solana wallet security workspace for encrypted keystores, desktop signing, mobile wallets, dApp signing, Pump trading, Squads multisig, bot integration, and advanced desktop-only Program workflows.

- - Crates.io + + Crates.io - - Documentation + + Documentation - + License - - GitHub stars - - - GitHub forks + + GitHub stars

Rust Solana - Security + Next.js + Tauri + Flutter

- 中文 | - English | + 中文 | + English | Website | Telegram | Discord @@ -41,231 +40,433 @@ --- -## 📑 Table of Contents +## Document Outline + +1. [Project Overview](#1-project-overview) + 1. [What FnzeroSafe Is For](#11-what-fnzerosafe-is-for) + 2. [Product Editions](#12-product-editions) + 3. [Capability Matrix](#13-capability-matrix) + 4. [Platform Matrix](#14-platform-matrix) +2. [Repository Layout](#2-repository-layout) +3. [Development Environment](#3-development-environment) + 1. [Required Toolchains](#31-required-toolchains) + 2. [One-Time Setup](#32-one-time-setup) + 3. [Host Notes](#33-host-notes) +4. [Run In Development](#4-run-in-development) + 1. [Desktop App](#41-desktop-app) + 2. [iOS App](#42-ios-app) + 3. [Android App](#43-android-app) + 4. [CLI](#44-cli) +5. [Package For Release](#5-package-for-release) + 1. [Release Directory](#51-release-directory) + 2. [macOS Desktop](#52-macos-desktop) + 3. [Windows Desktop](#53-windows-desktop) + 4. [iOS](#54-ios) + 5. [Android](#55-android) + 6. [All Platforms](#56-all-platforms) +6. [Command Reference](#6-command-reference) +7. [Configuration](#7-configuration) +8. [Security Model](#8-security-model) +9. [Documentation](#9-documentation) +10. [Before Submitting To GitHub](#10-before-submitting-to-github) +11. [License](#11-license) + +--- -- [What This Project Provides](#what-this-project-provides) -- [✨ Features](#-features) -- [🚀 Quick Start](#-quick-start) -- [📦 Installation](#-installation) -- [🖥️ Web and Desktop UI](#️-web-and-desktop-ui) -- [📋 Feature Guide](#-feature-guide) -- [📚 Documentation](#-documentation) -- [🔐 Security](#-security) -- [💬 Contact](#-contact) +## 1. Project Overview -## What This Project Provides +### 1.1 What FnzeroSafe Is For -Sol SafeKey is an open-source, local-first Solana wallet and key management toolkit. It combines a Rust SDK, CLI, self-hosted web wallet, and Tauri desktop app in one repository, with encrypted Keystore workflows for developers, bot operators, multisig teams, and individual wallet users. +FnzeroSafe is a local-first Solana wallet and key-management workspace. It combines a Rust core crate, an interactive CLI, a local desktop API, a Next.js web interface, a Tauri desktop shell, and a Flutter mobile app for iOS and Android. | Area | Coverage | -|------|----------| -| Interfaces | Rust SDK, interactive CLI, Next.js web wallet, Tauri desktop wallet | -| Wallet security | AES-256 encrypted Keystore files, password unlock, hardware fingerprint, optional TOTP 2FA/3FA | -| Solana operations | SOL and SPL Token transfers, Token-2022 assets, WSOL, durable nonce accounts, transaction history | -| Trading | Pump.fun and PumpSwap sells, balance shortcuts, cashback claims | -| Teams and programs | Squads v4 multisig proposals, program deployment, upgrades, and authority management | -| Automation | Bot helper APIs, local Rust API, examples, and bilingual documentation | - -### 📋 Feature Guide - -#### 🚀 Quick Start -**[Installation →](#-installation)** → **[Unlock Wallet](INTERACTIVE_TUTORIAL.md#u-unlock-wallet)** → **[Check Balance](INTERACTIVE_TUTORIAL.md#7-check-sol-balance)** - -#### 🔑 Core Functions -- **[1. Create Plaintext Key](INTERACTIVE_TUTORIAL.md#1-create-plaintext-key)** - Generate unencrypted keypair (testing only) -- **[2. Create Encrypted Key](INTERACTIVE_TUTORIAL.md#2-create-encrypted-key)** - Encrypt and save to keystore -- **[3. Decrypt Key](INTERACTIVE_TUTORIAL.md#3-decrypt-key)** - Decrypt keystore and reveal private key - -#### 🔒 Wallet Management -- **[U. Unlock Wallet](INTERACTIVE_TUTORIAL.md#u-unlock-wallet)** - Unlock wallet for Solana operations - -#### 🛡️ Advanced Security -- **[4. Setup 2FA](INTERACTIVE_TUTORIAL.md#4-setup-2fa-authentication)** - Configure two-factor authentication -- **[5. Generate Triple-Factor Wallet](INTERACTIVE_TUTORIAL.md#5-generate-triple-factor-wallet)** - Create 3FA wallet -- **[6. Unlock Triple-Factor Wallet](INTERACTIVE_TUTORIAL.md#6-unlock-triple-factor-wallet)** - Decrypt 3FA encrypted wallet - -#### 💰 Solana Operations -##### Balance & Transfer -- **[7. Check Balance](INTERACTIVE_TUTORIAL.md#7-check-sol-balance)** - Query SOL balance -- **[8. Transfer SOL](INTERACTIVE_TUTORIAL.md#8-transfer-sol)** - Send SOL - -##### WSOL Operations -- **[9. Create WSOL ATA](INTERACTIVE_TUTORIAL.md#9-create-wsol-ata)** - Create WSOL ATA -- **[10. Wrap SOL](INTERACTIVE_TUTORIAL.md#10-wrap-sol--wsol)** - SOL → WSOL -- **[11. Unwrap WSOL](INTERACTIVE_TUTORIAL.md#11-unwrap-wsol--sol)** - WSOL → SOL -- **[12. Close WSOL ATA](INTERACTIVE_TUTORIAL.md#12-close-wsol-ata)** - Close WSOL ATA - -##### Token Operations -- **[13. Transfer SPL Token](INTERACTIVE_TUTORIAL.md#13-transfer-spl-token)** - Send SPL tokens -- **[14. Create Nonce Account](INTERACTIVE_TUTORIAL.md#14-create-nonce-account)** - Create durable nonce - -##### DEX Operations -- **[15. Pump.fun Sell](INTERACTIVE_TUTORIAL.md#15-pumpfun-sell-token)** - Sell on Pump.fun DEX -- **[16. PumpSwap Sell](INTERACTIVE_TUTORIAL.md#16-pumpswap-sell-token)** - Sell on PumpSwap DEX - -##### Cashback Operations -- **[17. Pump.fun Cashback](INTERACTIVE_TUTORIAL.md#17-pumpfun-cashback-view--claim)** - View/claim pump.fun cashback (SOL) -- **[18. PumpSwap Cashback](INTERACTIVE_TUTORIAL.md#18-pumpswap-cashback-view--claim)** - View/claim PumpSwap cashback (WSOL) +|---|---| +| Wallets | Create wallets, import keystores/private keys/mnemonics, unlock wallets, export encrypted backups | +| Security | Password keystores, encrypted local API bodies, local API token, TOTP, biometric confirmation, sensitive log filtering | +| Assets | SOL balance, SPL Token accounts, Token-2022 accounts, mint metadata, transaction history | +| Payments | SOL transfer, SPL transfer, WSOL wrap/unwrap/close ATA | +| dApps | In-app WebView, Solana provider injection, message/transaction preview, user-confirmed signing | +| Squads | Squads v4 multisig create/info/proposals/approve/reject/execute, SOL/SPL payment proposals | +| Trading | Pump.fun and PumpSwap sell flows, cashback views and claims, SWQoS token configuration | +| Programs | Desktop-only Program deploy, upgrade, source build, authority and deployment workflows | +| Automation | Rust SDK, CLI helpers, local API, examples for bot integration | + +### 1.2 Product Editions + +| Edition | Path | Technology | Primary Use | +|---|---|---|---| +| Desktop app | `apps/desktop` | Next.js + Tauri + Rust API | Full local wallet console, dApp signing, Pump, Squads, Program workflows | +| Mobile app | `apps/mobile` | Flutter + `flutter_rust_bridge` + Rust core | iOS/Android wallet, assets, payments, dApp signing, Pump, Squads | +| Rust core / CLI | `crates/core` | Rust | Keystore, CLI, SDK-style integration and automation | +| Shared services | `crates/app-services` | Rust | Business logic shared by desktop and mobile | +| Mobile bridge | `crates/mobile-bridge` | Rust FFI | FRB-friendly API consumed by Flutter | + +### 1.3 Capability Matrix + +| Capability | Desktop | iOS | Android | Notes | +|---|---:|---:|---:|---| +| Wallet create/import/unlock/export | Yes | Yes | Yes | Secrets stay in encrypted keystores | +| SOL/SPL assets and history | Yes | Yes | Yes | Uses Solana RPC | +| SOL/SPL/WSOL payments | Yes | Yes | Yes | User confirmation required | +| dApp message signing | Yes | Yes | Yes | WebView/provider flow on mobile | +| dApp transaction signing/sending | Yes | Yes | Yes | User confirmation required | +| Squads multisig | Yes | Yes | Yes | Create, proposals, approve/reject/execute | +| PumpFun/PumpSwap | Yes | Partial mobile integration | Partial mobile integration | Mobile submit flows continue to mature | +| Program deploy | Yes | No | No | Desktop-only by design | +| Program upgrade | Yes | No | No | Desktop-only by design | +| Program source build | Yes | No | No | Desktop-only by design | +| Generic Program invoke | Yes | No | No | Desktop-only by design | + +### 1.4 Platform Matrix + +| Platform | Status | Main Command | Output | +|---|---|---|---| +| Desktop development | Supported | `make dev` | Tauri app, Next.js on `127.0.0.1:3840`, API on `127.0.0.1:3841` | +| macOS desktop package | Supported on macOS | `make package-macos` | `release/macos/*.dmg` and `.app` | +| Windows desktop package | Supported on Windows runner | `make package-windows` | `release/windows/*.msi` and/or `.exe` | +| iOS app package | Supported with Xcode iOS platform/signing | `make package-ios` | `release/ios/*.app` or `.ipa` | +| Android app package | Supported | `make package-android` | `release/android/*.apk` and `.aab` | +| CLI | Supported | `cargo run -p fnzero-safe-core --features full -- start` | Interactive terminal wallet tools | --- +## 2. Repository Layout + +```text +/ +├─ Cargo.toml +├─ Makefile +├─ crates/ +│ ├─ core/ # Rust SDK and CLI binary: fnzero-safe +│ ├─ app-services/ # Shared wallet/assets/payments/dApp/Squads services +│ ├─ desktop-api/ # Local Axum API used by desktop/web +│ └─ mobile-bridge/ # flutter_rust_bridge FFI layer +├─ apps/ +│ ├─ desktop/ # Next.js UI and Tauri desktop shell +│ └─ mobile/ # Flutter iOS/Android app +├─ packages/ +│ └─ shared-contracts/ # Shared API contracts and interface notes +├─ examples/ # Bot and keystore examples +├─ docs/ # Internal testing and development notes +└─ release/ # Package output directory, ignored by Git +``` + +The public product name is **FnzeroSafe**. Internal Cargo package names such as `fnzero-safe-core`, `fnzero-safe-desktop-api`, and `fnzero-safe-mobile-bridge` keep workspace crates unique. --- -## 📦 Installation +## 3. Development Environment -### Install from crates.io (Recommended) +### 3.1 Required Toolchains -The latest stable release is available on [crates.io](https://crates.io/crates/sol-safekey): +| Tool | Recommended Version | Used For | +|---|---|---| +| Rust | 1.89+ | Workspace, CLI, desktop API, mobile bridge | +| Node.js | 20 or 22+ | Desktop web app | +| npm | 10+ | Desktop dependencies and scripts | +| Flutter / Dart | Flutter 3.24+ | iOS and Android app | +| Xcode | Current stable | macOS desktop package, iOS simulator/device/archive | +| Android Studio / SDK / NDK | Current stable | Android builds and emulators | +| JDK | 17 | Android Gradle builds | +| `cargo-ndk` | Latest | Android Rust bridge libraries | +| `flutter_rust_bridge_codegen` | FRB 2.x | Regenerating Dart/Rust bridge code | + +### 3.2 One-Time Setup ```bash -# Install with all features enabled -cargo install sol-safekey --features full +rustup update +npm --version +flutter doctor -v +cargo install cargo-ndk +cargo install flutter_rust_bridge_codegen +npm --prefix apps/desktop install +cd apps/mobile && flutter pub get +``` -# Verify installation -sol-safekey --version +Regenerate mobile bridge bindings after changing `crates/mobile-bridge/src/api.rs`: -# Start interactive menu -sol-safekey start +```bash +cd apps/mobile +./tool/generate_bridge.sh ``` -This installs the complete CLI with all features: -- 🔐 **Core encryption** - AES-256 password-based encryption -- 🔑 **2FA/TOTP** - Two-factor authentication support -- 🌐 **Solana operations** - Full Solana transaction support -- 💱 **DEX operations** - Pump.fun & PumpSwap trading -- 💰 **Cashback** - Claim trading cashback rewards +### 3.3 Host Notes -### Install from source +| Host | Notes | +|---|---| +| macOS | Required for macOS desktop packages and iOS builds. Install the matching iOS platform/runtime from Xcode > Settings > Components. | +| Windows | Recommended for `make package-windows` with the MSVC target and Visual Studio build tools. | +| Android | Use JDK 17. Override auto-detection with `ANDROID_JAVA_HOME=/path/to/jdk17 make package-android`. | +| iOS signing | Unsigned `.app` builds can use `make package-ios`; signed IPA/TestFlight builds require an Apple developer team, provisioning profile, and export options. | -```bash -# Clone repository -git clone https://github.com/0xfnzero/sol-safekey.git -cd sol-safekey +--- -# Build and install -cargo install --path . --features full +## 4. Run In Development + +### 4.1 Desktop App + +Run from the repository root: + +```bash +npm --prefix apps/desktop install +make dev ``` -### System Requirements +`make dev` stops stale local development processes first, then starts: -- **Rust**: 1.89+ (install from [rustup.rs](https://rustup.rs/)) -- **Operating System**: Linux, macOS, or Windows -- **Network**: Internet connection for Solana RPC calls +| Service | URL | +|---|---| +| English UI | `http://127.0.0.1:3840/en/` | +| Chinese UI | `http://127.0.0.1:3840/zh/` | +| Local API health | `http://127.0.0.1:3841/api/health` | -## 🖥️ Web and Desktop UI +The local API is designed for loopback use only. Do not expose `3841` through a public proxy, tunnel, or port forward. -The self-hosted web wallet, local Rust API, and Tauri desktop app live in this repository under [`ui/`](https://github.com/0xfnzero/sol-safekey/tree/main/ui). The API depends directly on the root `sol-safekey` crate, so the CLI, Rust SDK, web wallet, and desktop wallet use one source tree and one Cargo lockfile. +### 4.2 iOS App ```bash -# Install JavaScript and Rust dependencies -make install +cd apps/mobile +./tool/bootstrap_mobile.sh +flutter pub get +./tool/generate_bridge.sh +./tool/build_ios_native.sh +flutter run -d ios +``` -# Start Next.js on :3840 and the local API on :3841 -make ui-dev +iOS requirements: -# Build the embedded web/API binary -make api-build +1. Xcode command line tools are selected with `xcode-select`. +2. The matching iOS platform/runtime is installed in Xcode Components. +3. iOS deployment target is 15.0. +4. Physical devices and TestFlight require normal Xcode signing setup. -# Start the Tauri desktop app -make desktop-dev +### 4.3 Android App + +```bash +cd apps/mobile +./tool/bootstrap_mobile.sh +flutter pub get +./tool/generate_bridge.sh +./tool/build_android_native.sh +flutter run -d android ``` -Open `http://127.0.0.1:3840/en/` or `http://127.0.0.1:3840/zh/` during development. The release API binary embeds the static frontend and serves it on `http://127.0.0.1:3841`. +Android notes: -### Create or import your first wallet +1. Android application id is `dev.fnzero.safe`. +2. Android `minSdk` is 26. +3. Rust bridge `.so` libraries are generated for `arm64-v8a`, `armeabi-v7a`, `x86`, and `x86_64`. +4. JDK 17 is recommended for Gradle. -1. Open the UI and select **Create New Wallet** or **Import Wallet**. -2. For a new wallet, enter a name and a 10-20 character password. For an existing wallet, select its `keystore.json` and enter its password. -3. Download and back up the encrypted Keystore before depositing funds. The password cannot be recovered. -4. Select the wallet from the wallet picker, then use **Receive**, **Send**, or **Trade** from the wallet page. -5. Check the selected network and RPC under **Settings** before signing any transaction. +### 4.4 CLI -See the **[UI Wallet User Guide](UI_USER_GUIDE.md)** for wallet management, transfers, trading, multisig workflows, backups, and troubleshooting. +```bash +cargo run -p fnzero-safe-core --features full -- start +``` -### Cargo Feature Flags +Install the CLI locally from source: -- `full` - Enable all features (default for CLI) -- `cli` - Command-line interface with colored output -- `2fa` - Two-factor authentication (TOTP) support -- `solana-ops` - Solana blockchain operations -- `sol-trade-sdk` - Trading operations (Pump.fun, PumpSwap) +```bash +cargo install --path crates/core --features full +fnzero-safe start +``` --- -## 📖 Complete Interactive Menu Guide +## 5. Package For Release -📖 **[View Complete Interactive Menu Guide → INTERACTIVE_TUTORIAL.md](INTERACTIVE_TUTORIAL.md)** +### 5.1 Release Directory -Comprehensive step-by-step guide for all interactive menu operations, including detailed instructions, use cases, and example outputs for each of the 18 menu options. +All package commands copy artifacts into the root `release/` directory: ---- +```text +release/ +├─ android/ +│ ├─ app-release.apk +│ └─ app-release.aab +├─ ios/ +│ └─ *.app or *.ipa +├─ macos/ +│ ├─ FnzeroSafe.app +│ └─ FnzeroSafe_*.dmg +└─ windows/ + └─ *.msi and/or *.exe +``` -## 📚 Documentation +`release/` is ignored by Git. -- **[UI Wallet User Guide](UI_USER_GUIDE.md)** - Create, import, back up, and use wallets in the web or desktop UI -- **[Bot Integration Guide](BOT_INTEGRATION.md)** - How to integrate sol-safekey into your bot -- **[User Guide](USER_GUIDE.md)** - Complete usage instructions and examples +### 5.2 macOS Desktop ---- +```bash +make package-macos +``` + +This runs the desktop production build and copies `.dmg` and `.app` artifacts into `release/macos/`. + +### 5.3 Windows Desktop -## 🔐 Security +```bash +make package-windows +``` -- ✅ **Password Security**: stdin pipe only (never in environment variables) -- ✅ **Encryption**: AES-256 with PBKDF2 key derivation -- ✅ **Memory Safety**: Immediate password cleanup after use -- ✅ **Hardware Fingerprint**: Device-based security layer -- ✅ **2FA Support**: Optional two-factor authentication for enhanced security +This builds the Tauri Windows package and copies `.msi` and/or `.exe` artifacts into `release/windows/`. +Recommended build environment: a Windows runner with Rust, Node.js, npm, and Microsoft Visual Studio build tools installed. Cross-building Windows installers from macOS requires extra toolchain setup. -## 🛠️ Available Operations +Override the target when needed: -### Wallet Management -- **Create Wallet** - Generate new encrypted wallet with AES-256 -- **Import Wallet** - Import existing wallet from private key or seed phrase -- **Export Wallet** - Export wallet to encrypted JSON format -- **View Address** - Display wallet public address +```bash +TAURI_WINDOWS_TARGET=x86_64-pc-windows-msvc make package-windows +``` -### SOL Operations -- **Query Balance** - Check SOL balance in your wallet -- **Transfer SOL** - Send SOL to other addresses -- **Wrap SOL** - Convert SOL to WSOL (Wrapped SOL) -- **Unwrap WSOL** - Convert WSOL back to SOL -- **Close WSOL ATA** - Close WSOL ATA account +### 5.4 iOS -### Token Operations -- **Transfer SPL Token** - Send SPL tokens to other addresses -- **Query Token Balance** - Check token balances +Unsigned local `.app` build: -### DEX Operations -- **🔥 Pump.fun Sell** - Interactive token selling on Pump.fun DEX (internal market only) -- **🔄 PumpSwap Sell** - Sell tokens on PumpSwap DEX +```bash +make package-ios +``` + +Signed IPA build: -### Cashback Operations -- **Pump.fun Cashback** - View and claim pump.fun cashback (native SOL) -- **PumpSwap Cashback** - View and claim PumpSwap cashback (WSOL) +```bash +IOS_CODESIGN=true IOS_EXPORT_OPTIONS_PLIST=/path/to/ExportOptions.plist make package-ios +``` -### Advanced Features -- **Durable Nonce Accounts** - Create and manage nonce accounts for offline signing -- **2FA Support** - Optional two-factor authentication for enhanced security +The command first builds the Rust iOS `FnzeroSafeMobileBridge.xcframework`, then runs the Flutter iOS package step and copies artifacts into `release/ios/`. -### Transaction Management -- **Check Transaction Status** - Query transaction status on Solana +### 5.5 Android + +```bash +make package-android +``` + +If JDK 17 is not auto-detected: + +```bash +ANDROID_JAVA_HOME=/path/to/jdk17 make package-android +``` + +Android release signing: + +```bash +cp apps/mobile/android/key.properties.example apps/mobile/android/key.properties +# Edit key.properties so storeFile, storePassword, keyAlias, and keyPassword point to your upload keystore. +make package-android +``` + +The command builds Rust Android native libraries, a release APK, and a release AAB, then copies artifacts into `release/android/`. + +### 5.6 All Platforms + +```bash +make package +``` + +This runs Android, iOS, macOS, and Windows package targets in sequence. For CI, run each platform on its native runner when possible. + +--- + +## 6. Command Reference + +| Command | Description | +|---|---| +| `make dev` | Start desktop development; stale local app/API processes are stopped first | +| `make package-macos` | Build macOS desktop package into `release/macos/` | +| `make package-windows` | Build Windows desktop package into `release/windows/` | +| `make package-ios` | Build iOS `.app` or signed `.ipa` into `release/ios/` | +| `make package-android` | Build Android APK/AAB into `release/android/` | +| `make package` | Build all package targets | +| `cargo fmt --all -- --check` | Check Rust formatting | +| `cargo check --workspace --all-features` | Check the full Rust workspace | +| `cargo test --workspace` | Run Rust tests | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings` | Run strict Rust linting | +| `npm --prefix apps/desktop run lint` | Run desktop lint and sensitive-input checks | +| `npm --prefix apps/desktop run build` | Build the Next.js static frontend | +| `cd apps/mobile && flutter analyze` | Analyze the Flutter app | +| `cd apps/mobile && flutter test --dart-define=FNZERO_MOBILE_DEV_BRIDGE=true` | Run Flutter tests with the dev bridge fallback | + +--- + +## 7. Configuration + +| Variable | Purpose | +|---|---| +| `FNZERO_SAFE_API_TOKEN` | Fixed local API token for desktop/web development | +| `FNZERO_SAFE_DB_PATH` | Override wallet database path | +| `FNZERO_SAFE_ALLOWED_ORIGINS` | Comma-separated list of additional trusted local API origins | +| `FNZERO_SAFE_ALLOW_SECRET_EXPORT=true` | Allow plaintext private key/mnemonic export from non-desktop local debugging contexts | +| `FNZERO_SAFE_ALLOW_DIRECT_SECRET_INPUT=true` | Allow direct plaintext private key submission from web debugging contexts | +| `FNZERO_MOBILE_DEV_BRIDGE=true` | Use Flutter dev bridge fallback for tests without native libraries | +| `ANDROID_JAVA_HOME` | JDK 17 path for Android package builds | +| `IOS_CODESIGN=true` | Build a signed iOS IPA instead of an unsigned `.app` | +| `IOS_EXPORT_OPTIONS_PLIST` | Export options plist path for signed iOS IPA builds | +| `TAURI_WINDOWS_TARGET` | Override the Tauri Windows target triple | +| `FNZERO_SAFE_FLASHBLOCK_SWQOS_API_TOKEN` | FlashBlock SWQoS token | +| `FNZERO_SAFE_BLOCKRAZOR_SWQOS_API_TOKEN` | BlockRazor SWQoS token | +| `FNZERO_SAFE_ASTRALANE_SWQOS_API_TOKEN` | Astralane SWQoS token | +| `FNZERO_SAFE_SPEEDLANDING_SWQOS_API_TOKEN` | SpeedLanding SWQoS token | + +Legacy `SOL_SAFEKEY_*` variables are still accepted as fallbacks for existing local setups. + +--- + +## 8. Security Model + +1. **Local-first API**: the desktop API binds to loopback and requires a local API token for protected routes. +2. **Encrypted sensitive requests**: password and secret-bearing JSON requests are encrypted before crossing the local web/API boundary. +3. **Keystore-first storage**: saved wallets store encrypted keystore JSON, not plaintext private keys. +4. **Mobile private storage**: mobile keystore files stay in app private storage; wallet metadata and biometric settings use secure storage. +5. **Biometric confirmation**: mobile signing actions can be gated by platform biometrics; desktop Touch ID uses macOS Keychain access control. +6. **Explicit signing confirmation**: payments, dApp signing, transaction sending, and Squads actions go through a confirmation screen. +7. **Plaintext export controls**: plaintext private key and mnemonic export are intentionally gated and should be used only for migration or local debugging. +8. **No mobile Program workflows**: mobile builds do not expose Program deploy, upgrade, source build, or generic invoke APIs. + +Always back up encrypted keystores before depositing funds. Passwords and seed phrases cannot be recovered by FnzeroSafe. --- -## 📖 Examples +## 9. Documentation -See `examples/bot_example.rs` for a complete bot integration example. +- [Desktop UI README](apps/desktop/README.md) +- [Mobile README](apps/mobile/README.md) +- [Mobile Internal Test Checklist](docs/mobile/INTERNAL_TEST_CHECKLIST.md) +- [UI Wallet User Guide](UI_USER_GUIDE.md) +- [Bot Integration Guide](BOT_INTEGRATION.md) +- [CLI User Guide](USER_GUIDE.md) +- [Interactive Tutorial](INTERACTIVE_TUTORIAL.md) +- [Program Deployment Guide](apps/desktop/PROGRAM_DEPLOYMENT.md) --- -## 🤝 Contributing +## 10. Before Submitting To GitHub + +Recommended validation: + +```bash +cargo fmt --all -- --check +cargo check --workspace --all-features +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +npm --prefix apps/desktop run lint +npm --prefix apps/desktop run build +cd apps/mobile && flutter analyze +cd apps/mobile && flutter test --dart-define=FNZERO_MOBILE_DEV_BRIDGE=true +``` + +Package validation when release artifacts are needed: + +```bash +make package-android +make package-macos +# Run package-ios on a macOS host with the required iOS platform/runtime installed. +# Run package-windows on a Windows host with MSVC build tools installed. +``` -Contributions welcome! Please ensure security best practices are followed. **Use English for commit and PR descriptions.** +For security-sensitive changes, include the threat model and the validation commands you ran in the pull request. --- -## 📄 License +## 11. License -MIT License - See LICENSE file for details +MIT License. See [LICENSE](LICENSE). diff --git a/README_CN.md b/README_CN.md index da146b0..a580c31 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,39 +1,38 @@

-

🔐 Sol SafeKey - 安全的 Solana 钱包与 Keystore

-

Rust SDK · CLI · Web UI · Tauri 桌面钱包

+

FnzeroSafe

+

本地优先的 Solana 钱包、安全 Keystore、桌面端与 iOS/Android 移动端应用

- 开源、本地优先的 Solana 钱包安全工具,支持加密 Keystore、2FA、Bot 集成、Token 操作、Pump 交易和 Squads 多签。 + FnzeroSafe 是一个开源 Solana 钱包安全工作区,覆盖加密 Keystore、桌面端签名、移动端钱包、dApp 签名、Pump 交易、Squads 多签、Bot 集成,以及仅桌面端开放的高级 Program 工作流。

- - Crates.io + + Crates.io - - Documentation + + Documentation - + License - - GitHub stars - - - GitHub forks + + GitHub stars

Rust Solana - Security + Next.js + Tauri + Flutter

- 中文 | - English | + 中文 | + English | 官网 | Telegram | Discord @@ -41,205 +40,433 @@ --- -## 📑 目录 +## 文档大纲 + +1. [项目概览](#1-项目概览) + 1. [FnzeroSafe 适合什么场景](#11-fnzerosafe-适合什么场景) + 2. [产品形态](#12-产品形态) + 3. [能力矩阵](#13-能力矩阵) + 4. [平台矩阵](#14-平台矩阵) +2. [仓库结构](#2-仓库结构) +3. [开发环境](#3-开发环境) + 1. [必要工具链](#31-必要工具链) + 2. [一次性初始化](#32-一次性初始化) + 3. [构建机说明](#33-构建机说明) +4. [开发环境运行](#4-开发环境运行) + 1. [桌面端](#41-桌面端) + 2. [iOS 端](#42-ios-端) + 3. [Android 端](#43-android-端) + 4. [CLI](#44-cli) +5. [打包发布](#5-打包发布) + 1. [Release 目录](#51-release-目录) + 2. [macOS 桌面端](#52-macos-桌面端) + 3. [Windows 桌面端](#53-windows-桌面端) + 4. [iOS](#54-ios) + 5. [Android](#55-android) + 6. [全部平台](#56-全部平台) +6. [命令速查](#6-命令速查) +7. [配置项](#7-配置项) +8. [安全模型](#8-安全模型) +9. [文档索引](#9-文档索引) +10. [提交到 GitHub 前](#10-提交到-github-前) +11. [许可证](#11-许可证) + +--- -- [这个项目提供什么](#这个项目提供什么) -- [✨ 特性](#-特性) -- [🚀 快速开始](#-快速开始) -- [📦 安装](#-安装) -- [🖥️ Web 与桌面 UI](#️-web-与桌面-ui) -- [📋 功能指南](#-功能指南) -- [📚 文档](#-文档) -- [🔐 安全性](#-安全性) -- [📖 示例](#-示例) -- [🤝 贡献](#-贡献) -- [📄 许可证](#-许可证) +## 1. 项目概览 + +### 1.1 FnzeroSafe 适合什么场景 + +FnzeroSafe 是一个本地优先的 Solana 钱包与密钥管理工作区。仓库内同时包含 Rust 核心库、交互式 CLI、本地桌面 API、Next.js 前端、Tauri 桌面壳,以及面向 iOS/Android 的 Flutter 移动端应用。 + +| 方向 | 覆盖范围 | +|---|---| +| 钱包 | 创建钱包、导入 keystore/私钥/助记词、解锁钱包、导出加密备份 | +| 安全 | 密码 Keystore、本地 API 敏感请求加密、本地 API token、TOTP、生物识别确认、敏感日志过滤 | +| 资产 | SOL 余额、SPL Token 账户、Token-2022 账户、mint 元数据、交易历史 | +| 转账 | SOL 转账、SPL 转账、WSOL wrap/unwrap/close ATA | +| dApp | 内置 WebView、Solana provider 注入、消息/交易预览、用户确认签名 | +| Squads | Squads v4 多签创建、查看、proposal、approve/reject/execute、SOL/SPL 支付 proposal | +| 交易 | Pump.fun 与 PumpSwap 卖出流程、返现查看与领取、SWQoS token 配置 | +| Program | 仅桌面端开放 Program 部署、升级、源码构建、权限与部署管理 | +| 自动化 | Rust SDK、CLI helper、本地 API、Bot 集成示例 | + +### 1.2 产品形态 + +| 形态 | 路径 | 技术栈 | 主要用途 | +|---|---|---|---| +| 桌面端应用 | `apps/desktop` | Next.js + Tauri + Rust API | 完整本地钱包控制台、dApp 签名、Pump、Squads、Program 工作流 | +| 移动端应用 | `apps/mobile` | Flutter + `flutter_rust_bridge` + Rust core | iOS/Android 钱包、资产、转账、dApp 签名、Pump、Squads | +| Rust core / CLI | `crates/core` | Rust | Keystore、CLI、SDK 集成和自动化 | +| 共享服务层 | `crates/app-services` | Rust | 桌面端与移动端复用的钱包/资产/转账/dApp/Squads 业务逻辑 | +| 移动端 bridge | `crates/mobile-bridge` | Rust FFI | Flutter 调用的 FRB-friendly API | + +### 1.3 能力矩阵 + +| 能力 | 桌面端 | iOS | Android | 说明 | +|---|---:|---:|---:|---| +| 钱包创建/导入/解锁/导出 | 支持 | 支持 | 支持 | Secret 保存在加密 Keystore 中 | +| SOL/SPL 资产和交易历史 | 支持 | 支持 | 支持 | 使用 Solana RPC | +| SOL/SPL/WSOL 转账 | 支持 | 支持 | 支持 | 必须用户确认 | +| dApp 消息签名 | 支持 | 支持 | 支持 | 移动端通过 WebView/provider 流程 | +| dApp 交易签名/发送 | 支持 | 支持 | 支持 | 必须用户确认 | +| Squads 多签 | 支持 | 支持 | 支持 | 创建、proposal、approve/reject/execute | +| PumpFun/PumpSwap | 支持 | 移动端部分集成 | 移动端部分集成 | 移动端 submit 流程持续完善 | +| Program deploy | 支持 | 不支持 | 不支持 | 设计上仅桌面端开放 | +| Program upgrade | 支持 | 不支持 | 不支持 | 设计上仅桌面端开放 | +| Program source build | 支持 | 不支持 | 不支持 | 设计上仅桌面端开放 | +| 通用 Program invoke | 支持 | 不支持 | 不支持 | 设计上仅桌面端开放 | + +### 1.4 平台矩阵 + +| 平台 | 状态 | 主要命令 | 输出 | +|---|---|---|---| +| 桌面端开发 | 支持 | `make dev` | Tauri 应用、`127.0.0.1:3840` 上的 Next.js、`127.0.0.1:3841` 上的 API | +| macOS 桌面端包 | macOS 构建机支持 | `make package-macos` | `release/macos/*.dmg` 和 `.app` | +| Windows 桌面端包 | Windows runner 支持 | `make package-windows` | `release/windows/*.msi` 和/或 `.exe` | +| iOS 应用包 | 需要 Xcode iOS platform/signing | `make package-ios` | `release/ios/*.app` 或 `.ipa` | +| Android 应用包 | 支持 | `make package-android` | `release/android/*.apk` 和 `.aab` | +| CLI | 支持 | `cargo run -p fnzero-safe-core --features full -- start` | 交互式终端钱包工具 | + +--- -## 这个项目提供什么 +## 2. 仓库结构 + +```text +/ +├─ Cargo.toml +├─ Makefile +├─ crates/ +│ ├─ core/ # Rust SDK 与 CLI 二进制:fnzero-safe +│ ├─ app-services/ # 共享钱包/资产/转账/dApp/Squads 服务 +│ ├─ desktop-api/ # 桌面端/Web 使用的本地 Axum API +│ └─ mobile-bridge/ # flutter_rust_bridge FFI 层 +├─ apps/ +│ ├─ desktop/ # Next.js UI 与 Tauri 桌面壳 +│ └─ mobile/ # Flutter iOS/Android 应用 +├─ packages/ +│ └─ shared-contracts/ # 共享 API 契约和接口说明 +├─ examples/ # Bot 与 Keystore 示例 +├─ docs/ # 内测和开发文档 +└─ release/ # 打包产物目录,已被 Git 忽略 +``` + +对外产品名统一是 **FnzeroSafe**。`fnzero-safe-core`、`fnzero-safe-desktop-api`、`fnzero-safe-mobile-bridge` 这类名称只是 Cargo workspace 内部包名,用来保证各 crate 名称唯一。 + +--- -Sol SafeKey 是一个开源、本地优先的 Solana 钱包和密钥管理工具。本仓库统一提供 Rust SDK、CLI、自托管 Web 钱包和 Tauri 桌面应用,适用于开发者、Bot 运营者、多签团队和个人钱包用户的加密 Keystore 工作流。 +## 3. 开发环境 -| 范围 | 覆盖内容 | -|------|----------| -| 使用界面 | Rust SDK、交互式 CLI、Next.js Web 钱包、Tauri 桌面钱包 | -| 钱包安全 | AES-256 加密 Keystore、密码解锁、硬件指纹、可选 TOTP 2FA/3FA | -| Solana 操作 | SOL 与 SPL Token 转账、Token-2022 资产、WSOL、Nonce 账户、交易记录 | -| 交易工具 | Pump.fun 与 PumpSwap 卖出、余额比例快捷操作、返现领取 | -| 团队与程序 | Squads v4 多签提案、程序部署、升级和权限管理 | -| 自动化集成 | Bot helper API、本地 Rust API、示例代码和中英双语文档 | +### 3.1 必要工具链 -### 📋 功能指南 +| 工具 | 推荐版本 | 用途 | +|---|---|---| +| Rust | 1.89+ | Workspace、CLI、桌面 API、移动端 bridge | +| Node.js | 20 或 22+ | 桌面端 Web 应用 | +| npm | 10+ | 桌面端依赖和脚本 | +| Flutter / Dart | Flutter 3.24+ | iOS 与 Android 应用 | +| Xcode | 当前稳定版 | macOS 桌面端包、iOS 模拟器/真机/archive | +| Android Studio / SDK / NDK | 当前稳定版 | Android 构建和模拟器 | +| JDK | 17 | Android Gradle 构建 | +| `cargo-ndk` | 最新版 | Android Rust bridge 动态库 | +| `flutter_rust_bridge_codegen` | FRB 2.x | 重新生成 Dart/Rust bridge 代码 | -#### 🎯 开始使用 +### 3.2 一次性初始化 -#### 步骤 1:启动交互式菜单 ```bash -sol-safekey start +rustup update +npm --version +flutter doctor -v +cargo install cargo-ndk +cargo install flutter_rust_bridge_codegen +npm --prefix apps/desktop install +cd apps/mobile && flutter pub get ``` -你将看到语言选择界面。选择你偏好的语言: +修改 `crates/mobile-bridge/src/api.rs` 后,需要重新生成移动端 bridge: -**英文**:输入 `2` -**中文**:输入 `1` +```bash +cd apps/mobile +./tool/generate_bridge.sh +``` -#### 步骤 2:选择一个操作 -选择语言后,你将看到主菜单。输入与你想执行的操作对应的数字。 +### 3.3 构建机说明 -**重要提示**:如果你还没有创建钱包,你需要: -- **解锁现有钱包**(选项 `U`) -- **创建新钱包**(选项 `1` 或 `2`) +| 构建机 | 说明 | +|---|---| +| macOS | macOS 桌面端包和 iOS 构建需要 macOS。请在 Xcode > Settings > Components 安装匹配的 iOS platform/runtime。 | +| Windows | 推荐在 Windows runner 上运行 `make package-windows`,并安装 MSVC target 和 Visual Studio build tools。 | +| Android | 推荐 JDK 17。可以用 `ANDROID_JAVA_HOME=/path/to/jdk17 make package-android` 覆盖自动检测。 | +| iOS 签名 | `make package-ios` 可做无签名 `.app` 构建;签名 IPA/TestFlight 需要 Apple developer team、profile 和 export options。 | --- -#### 🔑 核心功能(选项 1-3) -- **[1. 创建明文私钥](INTERACTIVE_TUTORIAL_CN.md#1-创建明文私钥(选项-1))** - 生成未加密密钥对(仅测试) -- **[2. 创建加密私钥](INTERACTIVE_TUTORIAL_CN.md)** - 加密并保存到 keystore -- **[3. 解密私钥](INTERACTIVE_TUTORIAL_CN.md#3-解密私钥(选项-3))** - 解密 keystore 显示私钥 +## 4. 开发环境运行 -#### 🔒 钱包管理(选项 U) -- **[U. 解锁钱包](INTERACTIVE_TUTORIAL_CN.md#u-解锁钱包)** - 解锁钱包进行 Solana 操作 +### 4.1 桌面端 -#### 🛡️ 高级安全(选项 4-6) -- **[4. 设置 2FA](INTERACTIVE_TUTORIAL_CN.md#4-设置-2fa-认证)** - 配置双因素认证 -- **[5. 生成三因子钱包](INTERACTIVE_TUTORIAL_CN.md#5-生成三因子钱包)** - 创建 3FA 钱包 -- **[6. 解锁三因子钱包](INTERACTIVE_TUTORIAL_CN.md#6-解锁三因子钱包)** - 解密 3FA 加密钱包 +在仓库根目录运行: -#### 💰 Solana 操作(选项 7-18) -##### 余额与转账 -- **[7. 查询余额](INTERACTIVE_TUTORIAL_CN.md#7-查询-sol-余额)** - 查询 SOL 余额 -- **[8. 转账 SOL](INTERACTIVE_TUTORIAL_CN.md#8-转账-sol)** - 发送 SOL +```bash +npm --prefix apps/desktop install +make dev +``` -##### WSOL 操作 -- **[9. 创建 WSOL ATA](INTERACTIVE_TUTORIAL_CN.md#9-创建-wsol-ata)** - 创建 WSOL 关联代币账户 -- **[10. 包装 SOL](INTERACTIVE_TUTORIAL_CN.md#10-包装-sol--wsol)** - SOL → WSOL -- **[11. 解包 WSOL](INTERACTIVE_TUTORIAL_CN.md#11-解包-wsol--sol)** - WSOL → SOL -- **[12. 关闭 WSOL ATA](INTERACTIVE_TUTORIAL_CN.md#12-关闭-wsol-ata)** - 关闭 WSOL ATA +`make dev` 会先停止旧的本地开发进程,然后启动: -##### 代币操作 -- **[13. 转账 SPL 代币](INTERACTIVE_TUTORIAL_CN.md#13-转账-spl-代币)** - 发送 SPL 代币 -- **[14. 创建 Nonce 账户](INTERACTIVE_TUTORIAL_CN.md#14-创建-nonce-账户)** - 创建持久化 nonce +| 服务 | 地址 | +|---|---| +| 英文 UI | `http://127.0.0.1:3840/en/` | +| 中文 UI | `http://127.0.0.1:3840/zh/` | +| 本地 API 健康检查 | `http://127.0.0.1:3841/api/health` | -##### DEX 操作 -- **[15. Pump.fun 卖出](INTERACTIVE_TUTORIAL_CN.md#15-pumpfun-卖出代币)** - Pump.fun DEX 卖出 -- **[16. PumpSwap 卖出](INTERACTIVE_TUTORIAL_CN.md#16-pumpswap-卖出代币)** - PumpSwap DEX 卖出 +本地 API 只应该绑定 loopback 使用。不要通过公网代理、隧道或端口转发暴露 `3841`。 -##### 返现操作 -- **[17. Pump.fun 返现](INTERACTIVE_TUTORIAL_CN.md#17-pumpfun-返现)** - pump.fun 返现(SOL) -- **[18. PumpSwap 返现](INTERACTIVE_TUTORIAL_CN.md#18-pumpswap-返现)** - PumpSwap 返现(WSOL) +### 4.2 iOS 端 ---- ---- +```bash +cd apps/mobile +./tool/bootstrap_mobile.sh +flutter pub get +./tool/generate_bridge.sh +./tool/build_ios_native.sh +flutter run -d ios +``` -## 📦 安装 +iOS 要求: -### 从 crates.io 安装(推荐) +1. 使用 `xcode-select` 选择正确的 Xcode command line tools。 +2. 在 Xcode Components 中安装匹配的 iOS platform/runtime。 +3. iOS deployment target 是 15.0。 +4. 真机和 TestFlight 需要常规 Xcode 签名配置。 -最新稳定版本已发布在 [crates.io](https://crates.io/crates/sol-safekey): +### 4.3 Android 端 ```bash -# 启用所有功能安装 -cargo install sol-safekey --features full +cd apps/mobile +./tool/bootstrap_mobile.sh +flutter pub get +./tool/generate_bridge.sh +./tool/build_android_native.sh +flutter run -d android +``` + +Android 说明: + +1. Android application id 是 `dev.fnzero.safe`。 +2. Android `minSdk` 是 26。 +3. Rust bridge `.so` 会生成 `arm64-v8a`、`armeabi-v7a`、`x86`、`x86_64` 四种 ABI。 +4. Gradle 推荐使用 JDK 17。 + +### 4.4 CLI + +```bash +cargo run -p fnzero-safe-core --features full -- start +``` + +从源码安装本地 CLI: + +```bash +cargo install --path crates/core --features full +fnzero-safe start +``` + +--- + +## 5. 打包发布 + +### 5.1 Release 目录 + +所有打包命令都会把产物复制到根目录 `release/`: -# 验证安装 -sol-safekey --version +```text +release/ +├─ android/ +│ ├─ app-release.apk +│ └─ app-release.aab +├─ ios/ +│ └─ *.app 或 *.ipa +├─ macos/ +│ ├─ FnzeroSafe.app +│ └─ FnzeroSafe_*.dmg +└─ windows/ + └─ *.msi 和/或 *.exe +``` + +`release/` 已被 Git 忽略。 + +### 5.2 macOS 桌面端 -# 启动交互式菜单 -sol-safekey start +```bash +make package-macos ``` -这将安装完整的 CLI 工具,包含所有功能: -- 🔐 **核心加密** - 基于 AES-256 的密码加密 -- 🔑 **2FA/TOTP** - 双因素认证支持 -- 🌐 **Solana 操作** - 完整的 Solana 交易功能 -- 💱 **DEX 操作** - Pump.fun & PumpSwap 交易 -- 💰 **返现** - 领取交易返现奖励 +该命令会执行桌面端生产构建,并把 `.dmg` 和 `.app` 复制到 `release/macos/`。 -### 从源码安装 +### 5.3 Windows 桌面端 ```bash -# 克隆仓库 -git clone https://github.com/0xfnzero/sol-safekey.git -cd sol-safekey +make package-windows +``` -# 编译并安装 -cargo install --path . --features full +该命令会构建 Tauri Windows 安装包,并把 `.msi` 和/或 `.exe` 复制到 `release/windows/`。 + +推荐构建环境:Windows runner,并安装 Rust、Node.js、npm 和 Microsoft Visual Studio build tools。从 macOS 交叉构建 Windows 安装包需要额外工具链配置。 + +需要覆盖目标平台时: + +```bash +TAURI_WINDOWS_TARGET=x86_64-pc-windows-msvc make package-windows ``` -### 系统要求 +### 5.4 iOS -- **Rust**: 1.89+ (从 [rustup.rs](https://rustup.rs/) 安装) -- **操作系统**: Linux、macOS 或 Windows -- **网络**: 进行 Solana RPC 调用需要互联网连接 +无签名本地 `.app` 构建: -## 🖥️ Web 与桌面 UI +```bash +make package-ios +``` -自托管 Web 钱包、本地 Rust API 与 Tauri 桌面应用统一放在本仓库的 [`ui/`](https://github.com/0xfnzero/sol-safekey/tree/main/ui) 下。API 直接依赖根目录的 `sol-safekey` crate,因此 CLI、Rust SDK、Web 钱包与桌面钱包共用同一份源码和 Cargo 锁文件。 +签名 IPA 构建: ```bash -# 安装 JavaScript 与 Rust 依赖 -make install +IOS_CODESIGN=true IOS_EXPORT_OPTIONS_PLIST=/path/to/ExportOptions.plist make package-ios +``` -# 启动 :3840 的 Next.js 与 :3841 的本地 API -make ui-dev +该命令会先构建 Rust iOS `FnzeroSafeMobileBridge.xcframework`,再执行 Flutter iOS 打包,并把产物复制到 `release/ios/`。 -# 构建内嵌 Web 页面的 API 二进制 -make api-build +### 5.5 Android -# 启动 Tauri 桌面应用 -make desktop-dev +```bash +make package-android ``` -开发时可访问 `http://127.0.0.1:3840/en/` 或 `http://127.0.0.1:3840/zh/`。发布版 API 二进制会内嵌静态前端,并在 `http://127.0.0.1:3841` 提供服务。 +如果 JDK 17 没有被自动发现: -### 创建或导入第一个钱包 +```bash +ANDROID_JAVA_HOME=/path/to/jdk17 make package-android +``` -1. 打开 UI,选择 **创建新钱包** 或 **导入钱包**。 -2. 创建钱包时填写名称和 10-20 位密码;导入钱包时选择 `keystore.json` 并输入对应密码。 -3. 存入资产前先下载并备份加密 Keystore。钱包密码无法找回。 -4. 在顶部钱包选择器中切换当前钱包,然后从钱包页使用 **接收**、**发送** 或 **交易**。 -5. 每次签名前,在 **设置** 中确认网络和 RPC 是否正确。 +Android release signing: -完整的钱包管理、转账、交易、多签、备份恢复与故障排查说明见 **[UI 钱包用户指南](UI_USER_GUIDE_CN.md)**。 +```bash +cp apps/mobile/android/key.properties.example apps/mobile/android/key.properties +# 编辑 key.properties,让 storeFile、storePassword、keyAlias、keyPassword 指向你的 upload keystore。 +make package-android +``` -### Cargo 功能特性 +该命令会构建 Rust Android native libraries、release APK 和 release AAB,并把产物复制到 `release/android/`。 -- `full` - 启用所有功能(CLI 默认) -- `cli` - 命令行界面(带彩色输出) -- `2fa` - 双因素认证(TOTP)支持 -- `solana-ops` - Solana 区块链操作 -- `sol-trade-sdk` - 交易操作(Pump.fun、PumpSwap) +### 5.6 全部平台 + +```bash +make package +``` + +该命令会依次运行 Android、iOS、macOS、Windows 打包目标。CI 中更推荐每个平台放到对应原生 runner 上分别构建。 --- -## 📚 文档 +## 6. 命令速查 + +| 命令 | 说明 | +|---|---| +| `make dev` | 启动桌面端开发环境;会先停止旧的本地应用/API 进程 | +| `make package-macos` | 构建 macOS 桌面端包到 `release/macos/` | +| `make package-windows` | 构建 Windows 桌面端包到 `release/windows/` | +| `make package-ios` | 构建 iOS `.app` 或签名 `.ipa` 到 `release/ios/` | +| `make package-android` | 构建 Android APK/AAB 到 `release/android/` | +| `make package` | 构建全部平台包 | +| `cargo fmt --all -- --check` | 检查 Rust 格式 | +| `cargo check --workspace --all-features` | 检查完整 Rust workspace | +| `cargo test --workspace` | 运行 Rust 测试 | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings` | 运行严格 Rust lint | +| `npm --prefix apps/desktop run lint` | 运行桌面端 lint 和敏感输入检查 | +| `npm --prefix apps/desktop run build` | 构建 Next.js 静态前端 | +| `cd apps/mobile && flutter analyze` | 分析 Flutter 应用 | +| `cd apps/mobile && flutter test --dart-define=FNZERO_MOBILE_DEV_BRIDGE=true` | 使用 dev bridge fallback 运行 Flutter 测试 | -- **[UI 钱包用户指南](UI_USER_GUIDE_CN.md)** - 在 Web 或桌面 UI 中创建、导入、备份和使用钱包 -- **[Bot 集成指南](BOT_INTEGRATION_CN.md)** - 如何将 sol-safekey 集成到你的 bot -- **[使用手册](USER_GUIDE_CN.md)** - 完整的使用说明和示例 +--- + +## 7. 配置项 + +| 变量 | 用途 | +|---|---| +| `FNZERO_SAFE_API_TOKEN` | 桌面/Web 开发使用的固定本地 API token | +| `FNZERO_SAFE_DB_PATH` | 覆盖钱包数据库路径 | +| `FNZERO_SAFE_ALLOWED_ORIGINS` | 额外允许访问本地 API 的 origin,多个用逗号分隔 | +| `FNZERO_SAFE_ALLOW_SECRET_EXPORT=true` | 允许非桌面本机调试上下文导出明文私钥/助记词 | +| `FNZERO_SAFE_ALLOW_DIRECT_SECRET_INPUT=true` | 允许 Web 调试上下文直接提交明文私钥 | +| `FNZERO_MOBILE_DEV_BRIDGE=true` | 无 native library 测试时使用 Flutter dev bridge fallback | +| `ANDROID_JAVA_HOME` | Android 打包使用的 JDK 17 路径 | +| `IOS_CODESIGN=true` | 构建签名 iOS IPA,而不是无签名 `.app` | +| `IOS_EXPORT_OPTIONS_PLIST` | 签名 iOS IPA 使用的 export options plist | +| `TAURI_WINDOWS_TARGET` | 覆盖 Tauri Windows target triple | +| `FNZERO_SAFE_FLASHBLOCK_SWQOS_API_TOKEN` | FlashBlock SWQoS token | +| `FNZERO_SAFE_BLOCKRAZOR_SWQOS_API_TOKEN` | BlockRazor SWQoS token | +| `FNZERO_SAFE_ASTRALANE_SWQOS_API_TOKEN` | Astralane SWQoS token | +| `FNZERO_SAFE_SPEEDLANDING_SWQOS_API_TOKEN` | SpeedLanding SWQoS token | + +旧的 `SOL_SAFEKEY_*` 环境变量仍作为 fallback 保留,用于兼容已经存在的本机配置。 --- -## 🔐 安全性 +## 8. 安全模型 -- ✅ **密码安全**:仅通过 stdin 管道(永不使用环境变量) -- ✅ **加密方式**:AES-256 配合 PBKDF2 密钥派生 -- ✅ **内存安全**:使用后立即清除密码 -- ✅ **硬件指纹**:基于设备的安全层 -- ✅ **2FA 支持**:可选的双重因素认证以增强安全性 +1. **本地优先 API**:桌面端 API 绑定 loopback,受保护路由需要本地 API token。 +2. **敏感请求加密**:包含密码和 secret 的 JSON 请求在跨本地 Web/API 边界前会先加密。 +3. **Keystore 优先**:已保存钱包存储的是加密 keystore JSON,不保存明文私钥。 +4. **移动端私有存储**:移动端 keystore 文件保存在 App 私有目录;钱包元数据和生物识别设置使用 secure storage。 +5. **生物识别确认**:移动端签名动作可以由系统生物识别二次确认保护;桌面端 Touch ID 使用 macOS Keychain 访问控制。 +6. **显式签名确认**:转账、dApp 签名、交易发送、Squads 操作都必须进入确认页。 +7. **明文导出控制**:明文私钥和助记词导出有意加限制,只应临时用于迁移或本机调试。 +8. **移动端不开放 Program 工作流**:移动端不暴露 Program deploy、upgrade、source build 或 generic invoke API。 +存入资产前请先备份加密 Keystore。密码、私钥、助记词无法由 FnzeroSafe 恢复。 + +--- -## 📖 示例 +## 9. 文档索引 -参见 `examples/bot_example.rs` 获取完整的 bot 集成示例。 +- [桌面端 README](apps/desktop/README.md) +- [移动端 README](apps/mobile/README.md) +- [移动端内测清单](docs/mobile/INTERNAL_TEST_CHECKLIST.md) +- [UI 钱包用户指南](UI_USER_GUIDE_CN.md) +- [Bot 集成指南](BOT_INTEGRATION_CN.md) +- [CLI 使用手册](USER_GUIDE_CN.md) +- [交互式教程](INTERACTIVE_TUTORIAL_CN.md) +- [Program 部署指南](apps/desktop/PROGRAM_DEPLOYMENT_CN.md) --- -## 🤝 贡献 +## 10. 提交到 GitHub 前 + +推荐验证: + +```bash +cargo fmt --all -- --check +cargo check --workspace --all-features +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +npm --prefix apps/desktop run lint +npm --prefix apps/desktop run build +cd apps/mobile && flutter analyze +cd apps/mobile && flutter test --dart-define=FNZERO_MOBILE_DEV_BRIDGE=true +``` + +需要提交发布产物前,可额外验证打包: + +```bash +make package-android +make package-macos +# package-ios 需要在已安装对应 iOS platform/runtime 的 macOS 上运行。 +# package-windows 推荐在安装 MSVC build tools 的 Windows 构建机上运行。 +``` -欢迎贡献!请确保遵循安全最佳实践。**提交与 PR 描述请使用英文。** +涉及安全敏感逻辑的改动,请在 PR 中写清楚威胁模型和已经执行的验证命令。 --- -## 📄 许可证 +## 11. 许可证 -MIT License - 详见 LICENSE 文件 +MIT License。详见 [LICENSE](LICENSE)。 diff --git a/UI_USER_GUIDE.md b/UI_USER_GUIDE.md index 60d89d4..9343fd9 100644 --- a/UI_USER_GUIDE.md +++ b/UI_USER_GUIDE.md @@ -1,8 +1,8 @@ -# Sol SafeKey UI Wallet User Guide +# FnzeroSafe UI Wallet User Guide [中文](UI_USER_GUIDE_CN.md) | [Project README](README.md) -Sol SafeKey UI is a local wallet interface backed by the `sol-safekey` Rust library. It supports encrypted Keystore wallets, asset and transaction views, SOL and token transfers, WSOL operations, Pump.fun and PumpSwap sells, durable nonce accounts, program deployment, and Squads v4 multisig workflows. +FnzeroSafe UI is a local wallet interface backed by the `fnzero-safe` Rust library. It supports encrypted Keystore wallets, asset and transaction views, SOL and token transfers, WSOL operations, Pump.fun and PumpSwap sells, durable nonce accounts, program deployment, and Squads v4 multisig workflows. ## Before you start @@ -19,8 +19,7 @@ The default configuration accepts saved Keystore wallets only. Direct private-ke From the repository root: ```bash -make install -make ui-dev +make dev ``` Open one of these addresses: @@ -28,13 +27,7 @@ Open one of these addresses: - English: `http://127.0.0.1:3840/en/` - Chinese: `http://127.0.0.1:3840/zh/` -`make ui-dev` starts both the Next.js interface on port `3840` and the local Rust API on port `3841`. Running only `npm run dev` does not start the wallet API. - -For the desktop application: - -```bash -make desktop-dev -``` +`make dev` stops stale local development processes, starts the desktop app, starts the Next.js interface on port `3840`, and starts the local Rust API on port `3841`. ## Create a wallet @@ -160,7 +153,7 @@ These operations can permanently affect programs or treasury assets. Confirm the The default wallet database is: ```text -ui/data/sol-safekey.sqlite3 +apps/desktop/data/fnzero-safe.sqlite3 ``` It contains encrypted wallet records and local metadata and is ignored by Git. Passwords are not persisted by the frontend. Sensitive request bodies are encrypted between the local UI or Tauri bridge and the local Rust API. @@ -170,14 +163,14 @@ Security boundaries to remember: - The database is not a substitute for an exported Keystore backup. - Anyone with a Keystore and its password can access the wallet. - Anyone with a plaintext private key can spend all wallet assets. -- Do not commit `ui/data/`, Keystores, passwords, environment files, or exported private keys. +- Do not commit `apps/desktop/data/`, Keystores, passwords, environment files, or exported private keys. - The API binds to `127.0.0.1` by default. Do not expose it to an untrusted network. ## Troubleshooting ### The page opens but wallet actions fail -Start the full stack with `make ui-dev`. Check the API at `http://127.0.0.1:3841/api/health`. +Start the full stack with `make dev`. Check the API at `http://127.0.0.1:3841/api/health`. ### The wallet or assets are missing @@ -193,4 +186,4 @@ Check the network, RPC health, wallet balance, fee balance, recipient or mint ad ### Ports 3840 or 3841 are already in use -Stop the existing Sol SafeKey development process, then run `make ui-dev` again. Avoid terminating unrelated applications that happen to use a different project directory. +Run `make dev` again. It stops stale local development processes before starting the app. diff --git a/UI_USER_GUIDE_CN.md b/UI_USER_GUIDE_CN.md index c842c71..2c65fbf 100644 --- a/UI_USER_GUIDE_CN.md +++ b/UI_USER_GUIDE_CN.md @@ -1,8 +1,8 @@ -# Sol SafeKey UI 钱包用户指南 +# FnzeroSafe UI 钱包用户指南 [English](UI_USER_GUIDE.md) | [项目 README](README_CN.md) -Sol SafeKey UI 是由 `sol-safekey` Rust 核心库驱动的本地钱包界面,支持加密 Keystore 钱包、资产和交易记录、SOL 与 Token 转账、WSOL、Pump.fun/PumpSwap 卖出、Nonce 账户、程序部署和 Squads v4 多签。 +FnzeroSafe UI 是由 `fnzero-safe` Rust 核心库驱动的本地钱包界面,支持加密 Keystore 钱包、资产和交易记录、SOL 与 Token 转账、WSOL、Pump.fun/PumpSwap 卖出、Nonce 账户、程序部署和 Squads v4 多签。 ## 使用前须知 @@ -19,8 +19,7 @@ Sol SafeKey UI 是由 `sol-safekey` Rust 核心库驱动的本地钱包界面, 在仓库根目录执行: ```bash -make install -make ui-dev +make dev ``` 打开以下任一地址: @@ -28,13 +27,7 @@ make ui-dev - 中文:`http://127.0.0.1:3840/zh/` - English:`http://127.0.0.1:3840/en/` -`make ui-dev` 会同时启动 `3840` 端口的 Next.js 界面和 `3841` 端口的本地 Rust API。只运行 `npm run dev` 不会启动钱包 API。 - -桌面应用使用: - -```bash -make desktop-dev -``` +`make dev` 会先停止旧的本地开发进程,再启动桌面应用、`3840` 端口的 Next.js 界面和 `3841` 端口的本地 Rust API。 ## 创建钱包 @@ -160,7 +153,7 @@ make desktop-dev 默认钱包数据库位于: ```text -ui/data/sol-safekey.sqlite3 +apps/desktop/data/fnzero-safe.sqlite3 ``` 数据库包含加密钱包记录和本地元数据,并已被 Git 忽略。前端不会持久化钱包密码。本地 UI 或 Tauri 安全桥与 Rust API 之间的敏感请求体会加密传输。 @@ -170,14 +163,14 @@ ui/data/sol-safekey.sqlite3 - 数据库不能替代导出的 Keystore 备份。 - 任何同时获得 Keystore 和密码的人都能访问钱包。 - 任何获得明文私钥的人都能支配钱包内全部资产。 -- 不要提交 `ui/data/`、Keystore、密码、环境文件或导出的私钥。 +- 不要提交 `apps/desktop/data/`、Keystore、密码、环境文件或导出的私钥。 - API 默认只监听 `127.0.0.1`,不要将其暴露到不可信网络。 ## 常见问题 ### 页面能打开,但钱包操作失败 -使用 `make ui-dev` 启动完整服务,并访问 `http://127.0.0.1:3841/api/health` 检查 API。 +使用 `make dev` 启动完整服务,并访问 `http://127.0.0.1:3841/api/health` 检查 API。 ### 钱包或资产没有显示 @@ -193,4 +186,4 @@ ui/data/sol-safekey.sqlite3 ### 3840 或 3841 端口被占用 -停止已有的 Sol SafeKey 开发进程后,再运行 `make ui-dev`。不要结束属于其他项目的无关进程。 +再次运行 `make dev`。它会在启动前停止旧的本地开发进程。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 97f0da0..5da4e89 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1,12 +1,12 @@ # User Guide -Complete guide for using sol-safekey interactive commands and operations. +Complete guide for using fnzero-safe interactive commands and operations. [中文文档](USER_GUIDE_CN.md) ## Getting Started -Sol-SafeKey provides an interactive command-line interface for secure Solana wallet management and operations. This guide covers all available features and operations. +FnzeroSafe provides an interactive command-line interface for secure Solana wallet management and operations. This guide covers all available features and operations. ### Prerequisites @@ -16,7 +16,7 @@ Sol-SafeKey provides an interactive command-line interface for secure Solana wal ### Accessing Interactive Menu -If you're using a bot that integrates sol-safekey: +If you're using a bot that integrates fnzero-safe: ```bash ./your-bot safekey @@ -30,20 +30,20 @@ Or if using the standalone binary: #### Direct Installation (Standalone) -For standalone usage without a bot, install sol-safekey directly: +For standalone usage without a bot, install fnzero-safe directly: ```bash # Install from crates.io (recommended for latest version) -cargo install sol-safekey --features="full" +cargo install fnzero-safe --features="full" # Verify installation -sol-safekey --version +fnzero-safe --version # Start interactive menu -sol-safekey start +fnzero-safe start ``` -This method provides direct access to all sol-safekey features through the interactive menu. +This method provides direct access to all fnzero-safe features through the interactive menu. ## Main Menu @@ -702,7 +702,7 @@ Sell tokens on **Pump.fun internal market** (bonding curve) for native SOL. Use - Bilingual prompts (English/Chinese) **Steps**: -1. Run `sol-safekey` (or `./your-bot safekey`) and unlock your wallet. +1. Run `fnzero-safe` (or `./your-bot safekey`) and unlock your wallet. 2. Choose **13** (Pump.fun Sell) or **16** (Pump.fun Sell, no 2FA prompt). 3. Enter RPC URL when prompted (or use default). 4. Choose whether to use seed-optimized ATA when asked. diff --git a/USER_GUIDE_CN.md b/USER_GUIDE_CN.md index d63f9fe..a96deed 100644 --- a/USER_GUIDE_CN.md +++ b/USER_GUIDE_CN.md @@ -6,7 +6,7 @@ Sol-safekey 交互命令和操作的完整使用指南。 ## 开始使用 -Sol-SafeKey 提供交互式命令行界面,用于安全的 Solana 钱包管理和操作。本指南涵盖所有可用功能和操作。 +FnzeroSafe 提供交互式命令行界面,用于安全的 Solana 钱包管理和操作。本指南涵盖所有可用功能和操作。 ### 前置要求 @@ -16,7 +16,7 @@ Sol-SafeKey 提供交互式命令行界面,用于安全的 Solana 钱包管理 ### 访问交互式菜单 -如果你使用的 bot 集成了 sol-safekey: +如果你使用的 bot 集成了 fnzero-safe: ```bash ./你的bot safekey @@ -30,20 +30,20 @@ Sol-SafeKey 提供交互式命令行界面,用于安全的 Solana 钱包管理 #### 直接安装(独立使用) -对于不使用 bot 的独立使用,直接安装 sol-safekey: +对于不使用 bot 的独立使用,直接安装 fnzero-safe: ```bash # 从 crates.io 安装(推荐使用最新版本) -cargo install sol-safekey --features="full" +cargo install fnzero-safe --features="full" # 验证安装 -sol-safekey --version +fnzero-safe --version # 启动交互式菜单 -sol-safekey start +fnzero-safe start ``` -这种方式提供通过交互式菜单直接访问所有 sol-safekey 功能。 +这种方式提供通过交互式菜单直接访问所有 fnzero-safe 功能。 ## 主菜单 @@ -702,7 +702,7 @@ Keystore 路径: keystore.json - 中英双语提示 **步骤**: -1. 运行 `sol-safekey`(或 `./你的bot safekey`)并解锁钱包。 +1. 运行 `fnzero-safe`(或 `./你的bot safekey`)并解锁钱包。 2. 选择 **13**(Pump.fun 卖出)或 **16**(Pump.fun 卖出,无 2FA 确认)。 3. 按提示输入 RPC URL(或使用默认)。 4. 按提示选择是否使用 seed 优化 ATA。 diff --git a/ui/PROGRAM_DEPLOYMENT.md b/apps/desktop/PROGRAM_DEPLOYMENT.md similarity index 85% rename from ui/PROGRAM_DEPLOYMENT.md rename to apps/desktop/PROGRAM_DEPLOYMENT.md index 43f45fd..7055a6d 100644 --- a/ui/PROGRAM_DEPLOYMENT.md +++ b/apps/desktop/PROGRAM_DEPLOYMENT.md @@ -1,8 +1,8 @@ -# Solana Program Deployment with Sol SafeKey +# Solana Program Deployment with FnzeroSafe -This guide covers the first deployment of an arbitrary Solana SBF program through the Sol SafeKey UI. It is for new upgradeable-loader Program IDs, not upgrades to an existing program. +This guide covers the first deployment of an arbitrary Solana SBF program through the FnzeroSafe UI. It is for new upgradeable-loader Program IDs, not upgrades to an existing program. -Sol SafeKey is a general-purpose wallet and program operations tool. It does not audit a contract, approve a release, or make an untrusted binary safe. Complete the program's security review, reproducible build, local-validator tests, and release approval before using this workflow. +FnzeroSafe is a general-purpose wallet and program operations tool. It does not audit a contract, approve a release, or make an untrusted binary safe. Complete the program's security review, reproducible build, local-validator tests, and release approval before using this workflow. Every deployment spends SOL and may submit many transactions. Use the local desktop application or a UI and API bound only to loopback. Never expose the local API through a public proxy, tunnel, or port forward. @@ -26,7 +26,7 @@ The Program keypair determines the Program ID and signs creation of the new Prog - Keep it outside the repository, downloads folder, shared drives, cloud sync, shell history, logs, tickets, and chat. - Do not print the JSON with `cat`, paste it into a terminal, or include it in screenshots or screen sharing. - Store an encrypted, access-controlled backup before deployment. Verify the backup without displaying its contents. -- Upload it only to the trusted local Sol SafeKey UI for the deployment request. Clear the form and close unneeded browser windows afterward. +- Upload it only to the trusted local FnzeroSafe UI for the deployment request. Clear the form and close unneeded browser windows afterward. - Stop if the Program ID derived by the UI differs from the approved Program ID. The Program keypair and Upgrade Authority are different roles. The Program keypair fixes the program address. After deployment, upgrades are controlled by the ProgramData Upgrade Authority, which is the selected deployment wallet in this workflow. Protect both credentials independently. @@ -55,7 +55,7 @@ Select the intended RPC profile before loading deployment material. Confirm all - An independent read-only check against the target RPC returns the same genesis hash. - The payer wallet is funded on that exact cluster. -An RPC profile label such as `devnet` is not proof of cluster identity. Sol SafeKey reads the RPC's actual genesis hash before unlocking the wallet or spending SOL and rejects a mismatch. For a custom RPC, confirm that it serves the intended cluster and that its genesis hash is supported by the deployment form. +An RPC profile label such as `devnet` is not proof of cluster identity. FnzeroSafe reads the RPC's actual genesis hash before unlocking the wallet or spending SOL and rejects a mismatch. For a custom RPC, confirm that it serves the intended cluster and that its genesis hash is supported by the deployment form. Do not work around a mismatch by changing the network label or expected hash. Stop and identify whether the RPC profile or release target is wrong. @@ -73,7 +73,7 @@ Record the approved value with the release evidence before signing. ## First deployment through the UI -1. Start Sol SafeKey locally and open the UI. Confirm the local API health endpoint before loading any key material. +1. Start FnzeroSafe locally and open the UI. Confirm the local API health endpoint before loading any key material. 2. Select the target RPC profile, then open **Program Workspace** and **Deploy Program**. 3. Select the saved deployment wallet. Verify that its public key is the intended payer and Upgrade Authority. 4. Upload the approved `.so`. Compare the displayed filename, byte length, and SHA-256 with the release record. @@ -91,7 +91,7 @@ The backend verifies the SBF binary before signing, checks the Program ID derive A first deployment may require create-buffer, multiple write, and deploy transactions. A timeout or lost response does not prove that a transaction failed. Never blindly retry. -Use the persisted deployment record shown by Sol SafeKey to determine the next action. Recovery must retain the exact same intent: +Use the persisted deployment record shown by FnzeroSafe to determine the next action. Recovery must retain the exact same intent: - cluster genesis hash and Program ID; - `.so` bytes, SHA-256, and byte length; @@ -116,7 +116,7 @@ Do not consider deployment complete merely because a transaction signature exist Download the deployment receipt immediately. Independently compute its SHA-256, archive it with the source revision, build metadata, artifact digest, Program ID approval, and operator review, and verify the Program on-chain through a separate read-only method. -A receipt records what Sol SafeKey observed and submitted. It is not a contract audit, source-to-binary proof, or external attestation. Any required reviewer signatures or deployment attestations must be created separately after checking the finalized readback. +A receipt records what FnzeroSafe observed and submitted. It is not a contract audit, source-to-binary proof, or external attestation. Any required reviewer signatures or deployment attestations must be created separately after checking the finalized readback. ## Stop conditions diff --git a/ui/PROGRAM_DEPLOYMENT_CN.md b/apps/desktop/PROGRAM_DEPLOYMENT_CN.md similarity index 83% rename from ui/PROGRAM_DEPLOYMENT_CN.md rename to apps/desktop/PROGRAM_DEPLOYMENT_CN.md index 1843d09..9e115c4 100644 --- a/ui/PROGRAM_DEPLOYMENT_CN.md +++ b/apps/desktop/PROGRAM_DEPLOYMENT_CN.md @@ -1,8 +1,8 @@ -# 使用 Sol SafeKey 部署 Solana Program +# 使用 FnzeroSafe 部署 Solana Program -本文说明如何通过 Sol SafeKey UI 首次部署任意 Solana SBF Program。该流程适用于新的 upgradeable-loader Program ID,不用于升级已经存在的 Program。 +本文说明如何通过 FnzeroSafe UI 首次部署任意 Solana SBF Program。该流程适用于新的 upgradeable-loader Program ID,不用于升级已经存在的 Program。 -Sol SafeKey 是通用钱包和 Program 运维工具。它不会自动完成合约审计、批准发布,也不会把不可信的二进制变得安全。进入本流程前,必须完成合约安全与业务逻辑审查、可复现构建、本地验证器测试和发布审批。 +FnzeroSafe 是通用钱包和 Program 运维工具。它不会自动完成合约审计、批准发布,也不会把不可信的二进制变得安全。进入本流程前,必须完成合约安全与业务逻辑审查、可复现构建、本地验证器测试和发布审批。 部署会消耗 SOL,并且可能发送多笔交易。应使用本地桌面应用,或仅绑定 loopback 的 UI 与 API。不要通过公网代理、隧道或端口转发暴露本地 API。 @@ -26,7 +26,7 @@ Program keypair 决定 Program ID,并为新 Program 账户的创建签名。 - 存放在代码仓库、下载目录、共享盘、云同步、shell 历史、日志、工单和聊天之外; - 不要用 `cat` 打印、粘贴到终端,也不要放入截图或屏幕共享; - 部署前创建加密且受访问控制的备份,并在不显示文件内容的前提下验证备份; -- 只上传到可信的本地 Sol SafeKey UI;请求结束后清空表单并关闭不需要的浏览器窗口; +- 只上传到可信的本地 FnzeroSafe UI;请求结束后清空表单并关闭不需要的浏览器窗口; - 如果 UI 派生出的 Program ID 与批准值不同,立即停止。 Program keypair 与 Upgrade Authority 是不同角色。Program keypair 固定 Program 地址;部署后,升级由 ProgramData Upgrade Authority 控制,本流程中就是所选部署钱包。两份凭据都需要独立保护。 @@ -55,7 +55,7 @@ Linux 可以使用 `sha256sum`。把独立批准的 64 位小写 SHA-256 填入 - 通过独立的只读检查,从目标 RPC 得到相同 genesis hash; - 付款钱包在该精确集群上有足够余额。 -`devnet` 等 RPC 配置标签不能证明集群身份。Sol SafeKey 会在解锁钱包或花费 SOL 前读取 RPC 的真实 genesis hash,不匹配时拒绝部署。使用自定义 RPC 时,必须确认它服务于预期集群,并且部署表单支持该 genesis hash。 +`devnet` 等 RPC 配置标签不能证明集群身份。FnzeroSafe 会在解锁钱包或花费 SOL 前读取 RPC 的真实 genesis hash,不匹配时拒绝部署。使用自定义 RPC 时,必须确认它服务于预期集群,并且部署表单支持该 genesis hash。 不要通过修改网络标签或 expected hash 绕过不匹配。应停止操作,查明 RPC 配置和发布目标中哪一项错误。 @@ -73,7 +73,7 @@ Linux 可以使用 `sha256sum`。把独立批准的 64 位小写 SHA-256 填入 ## 通过 UI 首次部署 -1. 在本机启动 Sol SafeKey 并打开 UI。载入任何密钥材料前,确认本地 API health endpoint 正常。 +1. 在本机启动 FnzeroSafe 并打开 UI。载入任何密钥材料前,确认本地 API health endpoint 正常。 2. 选择目标 RPC,然后进入“Program 工作区”并打开“部署 Program”。 3. 选择已保存的部署钱包,确认其公钥正是预期付款钱包和 Upgrade Authority。 4. 上传已批准的 `.so`,逐项比较 UI 显示的文件名、字节长度、SHA-256 与发布记录。 @@ -91,7 +91,7 @@ Linux 可以使用 `sha256sum`。把独立批准的 64 位小写 SHA-256 填入 首次部署可能包含 create-buffer、多笔 write 和 deploy 交易。超时或响应丢失不能证明交易失败,绝不能盲目重试。 -应根据 Sol SafeKey 显示的持久化部署记录决定下一步。恢复必须保持完全相同的部署意图: +应根据 FnzeroSafe 显示的持久化部署记录决定下一步。恢复必须保持完全相同的部署意图: - 集群 genesis hash 和 Program ID; - `.so` 字节、SHA-256 和字节长度; @@ -116,7 +116,7 @@ Linux 可以使用 `sha256sum`。把独立批准的 64 位小写 SHA-256 填入 立即下载 deployment receipt,独立计算 receipt 的 SHA-256,并与源码版本、构建 metadata、制品摘要、Program ID 批准记录和操作者复核证据一起归档。还应通过另一种只读方式独立核对链上 Program。 -Receipt 只记录 Sol SafeKey 观察和提交的内容,不是合约审计、源码到二进制证明或外部证明。任何要求的复核者签名或部署 attestation,都必须在核对 finalized 回读后另行生成。 +Receipt 只记录 FnzeroSafe 观察和提交的内容,不是合约审计、源码到二进制证明或外部证明。任何要求的复核者签名或部署 attestation,都必须在核对 finalized 回读后另行生成。 ## 必须停止的情况 diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..0c39172 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,59 @@ +# FnzeroSafe UI + +This directory contains the integrated Next.js interface, local Rust API, and Tauri desktop shell for FnzeroSafe. The API uses the root workspace crate through a local path dependency; do not clone or vendor another copy of `fnzero-safe` here. + +## User guides + +- [English UI wallet guide](../../UI_USER_GUIDE.md) +- [中文 UI 钱包指南](../../UI_USER_GUIDE_CN.md) +- [General Solana Program deployment guide](PROGRAM_DEPLOYMENT.md) +- [通用 Solana Program 部署指南](PROGRAM_DEPLOYMENT_CN.md) + +## Development + +Prerequisites: Rust 1.89 or newer for workspace/API builds, and Node.js 20 or 22 and newer (Node.js 21 is unsupported). The release gate uses Node.js 20.19.5 and npm 10.8.2 exactly. The standalone Tauri crate declares its independently audited Rust 1.88 minimum, but repository-wide commands require Rust 1.89. + +Run commands from the repository root: + +```bash +make dev +``` + +This command stops stale local development processes, starts the local web/API +development stack, and opens the Tauri shell. +Keep the app local; do not expose the API port through a public proxy, tunnel, or +port forward. + +中文:从同一个仓库根目录启动 Tauri 桌面端: + +```bash +make dev +``` + +该命令会先停止旧的本地开发进程,再启动本地 Web/API 开发栈并打开 Tauri 桌面壳。请保持本地运行, +不要通过公网代理、隧道或端口转发暴露 API 端口。 + +Development endpoints: + +- English UI: `http://127.0.0.1:3840/en/` +- Chinese UI: `http://127.0.0.1:3840/zh/` +- Local API: `http://127.0.0.1:3841/api/health` + +Build and check the integrated application with: + +```bash +make package-mac +make package-windows +``` + +## Layout + +```text +apps/desktop/src/ Next.js application and translations +crates/desktop-api/src/ Local Axum API and wallet persistence +apps/desktop/src-tauri/ Tauri desktop shell and secure API bridge +apps/desktop/public/ Token and application assets +apps/desktop/scripts/ Development and security checks +``` + +Wallet data is stored locally in `apps/desktop/data/` by default and is ignored by Git. Passwords remain in memory only long enough to process each request and are never persisted by the frontend. diff --git a/ui/components.json b/apps/desktop/components.json similarity index 100% rename from ui/components.json rename to apps/desktop/components.json diff --git a/ui/eslint.config.mjs b/apps/desktop/eslint.config.mjs similarity index 100% rename from ui/eslint.config.mjs rename to apps/desktop/eslint.config.mjs diff --git a/ui/next.config.ts b/apps/desktop/next.config.ts similarity index 97% rename from ui/next.config.ts rename to apps/desktop/next.config.ts index 35e6b3b..6f9bedb 100644 --- a/ui/next.config.ts +++ b/apps/desktop/next.config.ts @@ -7,6 +7,7 @@ const nextConfig: NextConfig = { // Static export for embedding in Rust binary. Keep dev server on the normal runtime // so rewrites work and it does not read stale export artifacts. ...(process.env.NODE_ENV === "production" ? { output: "export" as const } : {}), + devIndicators: false, basePath: '', trailingSlash: true, /** Dev-only: same-origin `/api` → Rust backend, avoids CORS when UI is on localhost:3840 */ diff --git a/ui/package-lock.json b/apps/desktop/package-lock.json similarity index 97% rename from ui/package-lock.json rename to apps/desktop/package-lock.json index 1e7c115..775f1d5 100644 --- a/ui/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@sol-safekey/ui", + "name": "@fnzero/safe", "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@sol-safekey/ui", + "name": "@fnzero/safe", "version": "0.4.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -16,6 +16,7 @@ "next": "^15.5.21", "next-intl": "^4.13.0", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "react": "19.0.0", "react-dom": "19.0.0", "sonner": "^2.0.7", @@ -25,12 +26,12 @@ "@eslint/eslintrc": "^3.3.5", "@tauri-apps/cli": "^2.10.1", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.4.27", "brace-expansion": "file:vendor/brace-expansion-compat", - "brace-expansion-upstream": "npm:brace-expansion@5.0.8", - "concurrently": "9.2.4", + "brace-expansion-upstream": "npm:brace-expansion@5.0.9", "eslint": "^9", "eslint-config-next": "^15.5.21", "postcss": "^8.5.23", @@ -1959,6 +1960,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2551,7 +2562,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2561,7 +2571,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2923,9 +2932,9 @@ }, "node_modules/brace-expansion-upstream": { "name": "brace-expansion", - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3042,6 +3051,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -3146,18 +3164,14 @@ "license": "MIT" }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", "license": "ISC", "dependencies": { "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" } }, "node_modules/clsx": { @@ -3173,7 +3187,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3186,50 +3199,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, - "node_modules/concurrently": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", - "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.9.0", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3344,6 +3315,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3403,6 +3383,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -4309,7 +4295,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4799,7 +4784,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5085,9 +5069,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5343,9 +5327,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -5795,6 +5779,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5812,7 +5805,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5873,6 +5865,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/po-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", @@ -6083,6 +6084,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6216,12 +6234,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -6304,16 +6327,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -6388,6 +6401,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6505,19 +6524,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -6638,7 +6644,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6653,7 +6658,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string.prototype.includes": { @@ -6773,7 +6777,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7066,16 +7069,6 @@ "node": ">=8.0" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -7455,6 +7448,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -7488,10 +7487,9 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7499,49 +7497,100 @@ "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "license": "MIT", "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" }, "engines": { - "node": ">=12" + "node": ">=8" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, "engines": { - "node": ">=12" + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/yocto-queue": { @@ -7559,11 +7608,11 @@ }, "vendor/brace-expansion-compat": { "name": "brace-expansion", - "version": "5.0.8", + "version": "5.0.9", "dev": true, "license": "MIT", "dependencies": { - "brace-expansion-upstream": "npm:brace-expansion@5.0.8" + "brace-expansion-upstream": "npm:brace-expansion@5.0.9" }, "engines": { "node": "20 || >=22" diff --git a/ui/package.json b/apps/desktop/package.json similarity index 78% rename from ui/package.json rename to apps/desktop/package.json index 6308ae7..fcc06ec 100644 --- a/ui/package.json +++ b/apps/desktop/package.json @@ -1,5 +1,5 @@ { - "name": "@sol-safekey/ui", + "name": "@fnzero/safe", "version": "0.4.0", "private": true, "engines": { @@ -7,9 +7,9 @@ }, "scripts": { "dev": "next dev -H 127.0.0.1 -p 3840", - "predev:stack": "node scripts/ensure-out.cjs && node scripts/free-api-port.cjs", - "dev:stack": "concurrently -k -n next,api \"next dev -H 127.0.0.1 -p 3840\" \"cargo run --release -p sol-safekey-ui\"", - "backend": "cargo run --release -p sol-safekey-ui", + "predev:stack": "node scripts/kill-dev-processes.cjs && node scripts/ensure-out.cjs", + "dev:stack": "node scripts/dev-stack.cjs", + "backend": "cargo run --release -p fnzero-safe-desktop-api", "prebuild": "node scripts/assert-no-next-dev.cjs && node scripts/check-sensitive-inputs.cjs", "build": "next build", "start": "next start", @@ -18,7 +18,7 @@ "test:program-deploy": "node scripts/test-program-deploy.cjs", "lint": "node scripts/check-sensitive-inputs.cjs && eslint", "tauri": "tauri", - "predesktop:dev": "node scripts/refresh-desktop-icons.cjs", + "predesktop:dev": "node scripts/kill-dev-processes.cjs && node scripts/refresh-desktop-icons.cjs", "desktop:dev": "node scripts/desktop-dev.cjs", "desktop:build": "tauri build" }, @@ -31,6 +31,7 @@ "next": "^15.5.21", "next-intl": "^4.13.0", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "react": "19.0.0", "react-dom": "19.0.0", "sonner": "^2.0.7", @@ -40,12 +41,12 @@ "@eslint/eslintrc": "^3.3.5", "@tauri-apps/cli": "^2.10.1", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.4.27", "brace-expansion": "file:vendor/brace-expansion-compat", - "brace-expansion-upstream": "npm:brace-expansion@5.0.8", - "concurrently": "9.2.4", + "brace-expansion-upstream": "npm:brace-expansion@5.0.9", "eslint": "^9", "eslint-config-next": "^15.5.21", "postcss": "^8.5.23", diff --git a/ui/postcss.config.js b/apps/desktop/postcss.config.js similarity index 100% rename from ui/postcss.config.js rename to apps/desktop/postcss.config.js diff --git a/ui/public/token-icons/bonk.jpg b/apps/desktop/public/token-icons/bonk.jpg similarity index 100% rename from ui/public/token-icons/bonk.jpg rename to apps/desktop/public/token-icons/bonk.jpg diff --git a/ui/public/token-icons/jitosol.png b/apps/desktop/public/token-icons/jitosol.png similarity index 100% rename from ui/public/token-icons/jitosol.png rename to apps/desktop/public/token-icons/jitosol.png diff --git a/ui/public/token-icons/jto.webp b/apps/desktop/public/token-icons/jto.webp similarity index 100% rename from ui/public/token-icons/jto.webp rename to apps/desktop/public/token-icons/jto.webp diff --git a/ui/public/token-icons/jup.png b/apps/desktop/public/token-icons/jup.png similarity index 100% rename from ui/public/token-icons/jup.png rename to apps/desktop/public/token-icons/jup.png diff --git a/ui/public/token-icons/msol.png b/apps/desktop/public/token-icons/msol.png similarity index 100% rename from ui/public/token-icons/msol.png rename to apps/desktop/public/token-icons/msol.png diff --git a/ui/public/token-icons/pyth.png b/apps/desktop/public/token-icons/pyth.png similarity index 100% rename from ui/public/token-icons/pyth.png rename to apps/desktop/public/token-icons/pyth.png diff --git a/ui/public/token-icons/solana.png b/apps/desktop/public/token-icons/solana.png similarity index 100% rename from ui/public/token-icons/solana.png rename to apps/desktop/public/token-icons/solana.png diff --git a/ui/public/token-icons/usdc.png b/apps/desktop/public/token-icons/usdc.png similarity index 100% rename from ui/public/token-icons/usdc.png rename to apps/desktop/public/token-icons/usdc.png diff --git a/ui/public/token-icons/usdt.png b/apps/desktop/public/token-icons/usdt.png similarity index 100% rename from ui/public/token-icons/usdt.png rename to apps/desktop/public/token-icons/usdt.png diff --git a/ui/public/token-icons/wif.jpg b/apps/desktop/public/token-icons/wif.jpg similarity index 100% rename from ui/public/token-icons/wif.jpg rename to apps/desktop/public/token-icons/wif.jpg diff --git a/ui/scripts/assert-no-next-dev.cjs b/apps/desktop/scripts/assert-no-next-dev.cjs similarity index 100% rename from ui/scripts/assert-no-next-dev.cjs rename to apps/desktop/scripts/assert-no-next-dev.cjs diff --git a/ui/scripts/check-sensitive-inputs.cjs b/apps/desktop/scripts/check-sensitive-inputs.cjs similarity index 100% rename from ui/scripts/check-sensitive-inputs.cjs rename to apps/desktop/scripts/check-sensitive-inputs.cjs diff --git a/ui/scripts/desktop-dev.cjs b/apps/desktop/scripts/desktop-dev.cjs similarity index 83% rename from ui/scripts/desktop-dev.cjs rename to apps/desktop/scripts/desktop-dev.cjs index ba706d2..d8cd69e 100644 --- a/ui/scripts/desktop-dev.cjs +++ b/apps/desktop/scripts/desktop-dev.cjs @@ -1,4 +1,5 @@ const { spawn } = require("node:child_process"); +const { randomBytes } = require("node:crypto"); const root = require("node:path").resolve(__dirname, ".."); const isWindows = process.platform === "win32"; @@ -13,12 +14,18 @@ function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -function spawnManaged(label, command, args) { +function sharedApiToken() { + const existing = String(process.env.FNZERO_SAFE_API_TOKEN || process.env.SOL_SAFEKEY_API_TOKEN || "").trim(); + return existing || randomBytes(32).toString("base64url"); +} + +function spawnManaged(label, command, args, env = process.env) { const child = spawn(command, args, { cwd: root, stdio: "inherit", shell: isWindows, detached: !isWindows, + env, }); child.on("error", (error) => { @@ -131,7 +138,16 @@ async function waitForDevStack(timeoutMs = 120000) { } async function main() { - devStack = spawnManaged("dev stack", "npm", ["run", "dev:stack"]); + const token = sharedApiToken(); + const sharedEnv = { + ...process.env, + FNZERO_SAFE_API_TOKEN: token, + NEXT_PUBLIC_FNZERO_SAFE_API_TOKEN: token, + SOL_SAFEKEY_API_TOKEN: token, + NEXT_PUBLIC_SOL_SAFEKEY_API_TOKEN: token, + }; + + devStack = spawnManaged("dev stack", "npm", ["run", "dev:stack"], sharedEnv); devStack.once("exit", (code, signal) => { devStackExitStatus = { code, signal }; if (!shuttingDown && desktopLaunchStarted) { @@ -148,14 +164,19 @@ async function main() { console.log("[desktop:dev] starting Tauri after the web/API stack is ready"); desktopLaunchStarted = true; - tauri = spawnManaged("Tauri", "npm", [ - "run", - "tauri", - "--", - "dev", - "--config", - "src-tauri/tauri.desktop-dev.conf.json", - ]); + tauri = spawnManaged( + "Tauri", + "npm", + [ + "run", + "tauri", + "--", + "dev", + "--config", + "src-tauri/tauri.desktop-dev.conf.json", + ], + sharedEnv, + ); const { code, signal } = await waitForExit(tauri); cleanup(); diff --git a/apps/desktop/scripts/dev-stack.cjs b/apps/desktop/scripts/dev-stack.cjs new file mode 100644 index 0000000..7b4b03a --- /dev/null +++ b/apps/desktop/scripts/dev-stack.cjs @@ -0,0 +1,89 @@ +const { spawn } = require("node:child_process"); +const { randomBytes } = require("node:crypto"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const isWindows = process.platform === "win32"; +let shuttingDown = false; +const children = []; + +function sharedApiToken() { + const existing = String(process.env.FNZERO_SAFE_API_TOKEN || process.env.SOL_SAFEKEY_API_TOKEN || "").trim(); + return existing || randomBytes(32).toString("base64url"); +} + +function spawnManaged(label, command, args, env) { + const child = spawn(command, args, { + cwd: root, + stdio: "inherit", + shell: isWindows, + detached: !isWindows, + env, + }); + children.push(child); + + child.on("error", (error) => { + if (!shuttingDown) { + console.error(`[dev:stack] ${label} failed to start: ${error.message}`); + } + }); + + child.once("exit", (code, signal) => { + if (!shuttingDown) { + console.error(`[dev:stack] ${label} exited (${signal || `code ${code ?? 0}`}); stopping stack`); + cleanup(); + process.exit(code ?? (signal ? 1 : 0)); + } + }); + + return child; +} + +function stopProcess(child, signal = "SIGTERM") { + if (!child || !child.pid || child.exitCode !== null || child.killed) return; + try { + if (isWindows) { + child.kill(signal); + } else { + process.kill(-child.pid, signal); + } + } catch { + try { + child.kill(signal); + } catch { + /* ignore */ + } + } +} + +function cleanup(signal = "SIGTERM") { + if (shuttingDown) return; + shuttingDown = true; + for (const child of children) { + stopProcess(child, signal); + } +} + +const token = sharedApiToken(); +const env = { + ...process.env, + FNZERO_SAFE_API_TOKEN: token, + NEXT_PUBLIC_FNZERO_SAFE_API_TOKEN: token, + SOL_SAFEKEY_API_TOKEN: token, + NEXT_PUBLIC_SOL_SAFEKEY_API_TOKEN: token, +}; + +spawnManaged("Next.js", "npm", ["exec", "--", "next", "dev", "-H", "127.0.0.1", "-p", "3840"], env); +spawnManaged("Rust API", "cargo", ["run", "--release", "-p", "fnzero-safe-desktop-api"], env); + +process.once("SIGINT", () => { + cleanup("SIGINT"); + process.exit(130); +}); + +process.once("SIGTERM", () => { + cleanup("SIGTERM"); + process.exit(143); +}); + +process.once("exit", () => cleanup()); diff --git a/ui/scripts/ensure-out.cjs b/apps/desktop/scripts/ensure-out.cjs similarity index 100% rename from ui/scripts/ensure-out.cjs rename to apps/desktop/scripts/ensure-out.cjs diff --git a/ui/scripts/free-api-port.cjs b/apps/desktop/scripts/free-api-port.cjs similarity index 83% rename from ui/scripts/free-api-port.cjs rename to apps/desktop/scripts/free-api-port.cjs index 1c76739..5cb584a 100644 --- a/ui/scripts/free-api-port.cjs +++ b/apps/desktop/scripts/free-api-port.cjs @@ -1,12 +1,12 @@ /** * Free DEFAULT_API_PORT (3841) before `cargo run`, so `desktop:dev` does not fail with - * EADDRINUSE when a previous sol-safekey-ui is still running. + * EADDRINUSE when a previous fnzero-safe is still running. * * macOS / Linux: lsof. Windows: no-op (run Task Manager or `netstat` manually if needed). */ const { execSync } = require("child_process"); -const port = process.env.SOL_SAFEKEY_API_PORT || "3841"; +const port = process.env.FNZERO_SAFE_API_PORT || process.env.SOL_SAFEKEY_API_PORT || "3841"; if (process.platform === "win32") { process.exit(0); diff --git a/apps/desktop/scripts/kill-dev-processes.cjs b/apps/desktop/scripts/kill-dev-processes.cjs new file mode 100644 index 0000000..a1537b1 --- /dev/null +++ b/apps/desktop/scripts/kill-dev-processes.cjs @@ -0,0 +1,141 @@ +const { execSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const uiRoot = path.resolve(__dirname, ".."); +const workspaceRoot = path.resolve(uiRoot, ".."); +const ports = ["3840", process.env.FNZERO_SAFE_API_PORT || process.env.SOL_SAFEKEY_API_PORT || "3841"]; +const isWindows = process.platform === "win32"; + +function exec(command) { + try { + return execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + return ""; + } +} + +function commandLine(pid) { + if (isWindows) return ""; + return exec(`ps -p ${pid} -o command=`); +} + +function processCwd(pid) { + if (isWindows) return ""; + try { + return fs.realpathSync(`/proc/${pid}/cwd`); + } catch { + if (process.platform === "darwin") { + const out = exec(`lsof -a -d cwd -p ${pid} -Fn`); + const cwd = out.split("\n").find((line) => line.startsWith("n"))?.slice(1) || ""; + try { + return cwd ? fs.realpathSync(cwd) : ""; + } catch { + return cwd; + } + } + return ""; + } +} + +function ancestorPids() { + const pids = new Set([process.pid]); + let pid = process.ppid; + while (pid && !pids.has(pid)) { + pids.add(pid); + const parent = Number(exec(`ps -p ${pid} -o ppid=`)); + if (!Number.isInteger(parent) || parent <= 1) break; + pid = parent; + } + return pids; +} + +function pidsListeningOnPort(port) { + if (isWindows) { + const command = [ + "powershell", + "-NoProfile", + "-Command", + `"Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess"`, + ].join(" "); + return exec(command).split(/\s+/).filter(Boolean); + } + return exec(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`).split(/\s+/).filter(Boolean); +} + +function matchingProjectPids() { + if (isWindows) return []; + const patterns = [ + "next (dev|build)", + "next-server", + "tauri dev", + "scripts/dev-stack.cjs", + "scripts/desktop-dev.cjs", + "build-cache/debug/FnzeroSafe", + "build-cache/release/fnzero-safe-desktop-api", + ]; + const pids = new Set(); + for (const pattern of patterns) { + for (const pid of exec(`pgrep -f '${pattern}'`).split(/\s+/).filter(Boolean)) { + const cwd = processCwd(pid); + const command = commandLine(pid); + if ( + cwd === uiRoot || + cwd === workspaceRoot || + command.includes(`${uiRoot}${path.sep}`) || + command.includes(`${workspaceRoot}${path.sep}`) + ) { + pids.add(pid); + } + } + } + return [...pids]; +} + +function isRunning(pid) { + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } +} + +function stopPids(pids) { + const protectedPids = ancestorPids(); + const targets = [...new Set(pids.map(Number))] + .filter((pid) => Number.isInteger(pid) && pid > 1 && !protectedPids.has(pid)); + if (targets.length === 0) return; + + console.log(`[dev:cleanup] stopping stale process PID(s): ${targets.join(", ")}`); + for (const pid of targets) { + try { + process.kill(pid, "SIGTERM"); + } catch { + /* ignore */ + } + } + + const deadline = Date.now() + 3000; + while (targets.some(isRunning) && Date.now() < deadline) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + } + + for (const pid of targets) { + if (!isRunning(pid)) continue; + console.log(`[dev:cleanup] force stopping stale process ${pid}`); + try { + process.kill(pid, "SIGKILL"); + } catch { + /* ignore */ + } + } +} + +const pids = new Set(matchingProjectPids()); +for (const port of ports) { + for (const pid of pidsListeningOnPort(port)) { + pids.add(pid); + } +} +stopPids([...pids]); diff --git a/ui/scripts/refresh-desktop-icons.cjs b/apps/desktop/scripts/refresh-desktop-icons.cjs similarity index 94% rename from ui/scripts/refresh-desktop-icons.cjs rename to apps/desktop/scripts/refresh-desktop-icons.cjs index 4896bed..cb43e4a 100644 --- a/ui/scripts/refresh-desktop-icons.cjs +++ b/apps/desktop/scripts/refresh-desktop-icons.cjs @@ -6,7 +6,7 @@ const repoRoot = path.resolve(__dirname, ".."); const tauriRoot = path.join(repoRoot, "src-tauri"); const sourceIcon = path.join(tauriRoot, "icons", "icon.icns"); const buildScript = path.join(tauriRoot, "build.rs"); -const debugApp = path.join(tauriRoot, "target", "debug", "app"); +const debugApp = path.join(tauriRoot, "target", "debug", "FnzeroSafe"); const existingBundleIcons = [ path.join( @@ -15,7 +15,7 @@ const existingBundleIcons = [ "release", "bundle", "macos", - "Sol SafeKey.app", + "FnzeroSafe.app", "Contents", "Resources", "icon.icns", diff --git a/ui/scripts/test-dependency-compat.cjs b/apps/desktop/scripts/test-dependency-compat.cjs similarity index 99% rename from ui/scripts/test-dependency-compat.cjs rename to apps/desktop/scripts/test-dependency-compat.cjs index 04d1321..be821c6 100644 --- a/ui/scripts/test-dependency-compat.cjs +++ b/apps/desktop/scripts/test-dependency-compat.cjs @@ -10,8 +10,8 @@ const numericExpected = ["item-1", "item-2", "item-3"]; async function main() { const braceExpansion = require("brace-expansion"); - assert.equal(require("brace-expansion/package.json").version, "5.0.8"); - assert.equal(require("brace-expansion-upstream/package.json").version, "5.0.8"); + assert.equal(require("brace-expansion/package.json").version, "5.0.9"); + assert.equal(require("brace-expansion-upstream/package.json").version, "5.0.9"); assert.equal(typeof braceExpansion, "function"); assert.equal(typeof braceExpansion.expand, "function"); assert.equal(braceExpansion.EXPANSION_MAX, 100_000); diff --git a/ui/scripts/test-devnet-airdrop.cjs b/apps/desktop/scripts/test-devnet-airdrop.cjs similarity index 95% rename from ui/scripts/test-devnet-airdrop.cjs rename to apps/desktop/scripts/test-devnet-airdrop.cjs index bce2624..23a9273 100644 --- a/ui/scripts/test-devnet-airdrop.cjs +++ b/apps/desktop/scripts/test-devnet-airdrop.cjs @@ -3,6 +3,7 @@ const fs = require("node:fs"); const path = require("node:path"); const uiRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(uiRoot, "..", ".."); const page = fs.readFileSync( path.join(uiRoot, "src", "app", "[locale]", "page.tsx"), "utf8", @@ -11,7 +12,10 @@ const openExternal = fs.readFileSync( path.join(uiRoot, "src", "lib", "openExternal.ts"), "utf8", ); -const backend = fs.readFileSync(path.join(uiRoot, "backend", "main.rs"), "utf8"); +const backend = fs.readFileSync( + path.join(repoRoot, "crates", "desktop-api", "src", "main.rs"), + "utf8", +); const tauriLib = fs.readFileSync( path.join(uiRoot, "src-tauri", "src", "lib.rs"), "utf8", diff --git a/ui/scripts/test-program-deploy.cjs b/apps/desktop/scripts/test-program-deploy.cjs similarity index 92% rename from ui/scripts/test-program-deploy.cjs rename to apps/desktop/scripts/test-program-deploy.cjs index 43070db..88e9a65 100644 --- a/ui/scripts/test-program-deploy.cjs +++ b/apps/desktop/scripts/test-program-deploy.cjs @@ -5,26 +5,25 @@ const os = require("node:os"); const path = require("node:path"); const uiRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(uiRoot, "..", ".."); +const deploymentPage = fs.readFileSync( + path.join(uiRoot, "src", "app", "[locale]", "page.tsx"), + "utf8", +); const genericBoundaryFiles = [ - path.join("src", "app", "[locale]", "page.tsx"), path.join("src", "components", "FieldHelp.tsx"), path.join("src", "lib", "apiPath.ts"), path.join("src", "lib", "apiPath.test.ts"), - path.join("src", "lib", "appStorage.ts"), - path.join("src", "lib", "appStorage.test.ts"), + path.join("src", "lib", "anchorIdl.ts"), + path.join("src", "lib", "anchorIdl.test.ts"), path.join("src", "lib", "programDeploy.ts"), path.join("src", "lib", "programDeploy.test.ts"), - path.join("src", "lib", "programWorkspace.ts"), - path.join("src", "lib", "programWorkspace.test.ts"), - path.join("src", "messages", "en.json"), - path.join("src", "messages", "zh.json"), ]; const genericBoundarySources = genericBoundaryFiles.map((relativePath) => [ relativePath, fs.readFileSync(path.join(uiRoot, relativePath), "utf8"), ]); -const deploymentPage = genericBoundarySources[0][1]; -const fieldHelpComponent = genericBoundarySources[1][1]; +const fieldHelpComponent = genericBoundarySources[0][1]; const projectSpecificName = ["fn", "zero"].join(""); const projectSpecificNamePattern = new RegExp(projectSpecificName, "i"); @@ -43,7 +42,10 @@ assert.match(deploymentPage, /removeProgramDeploymentHistoryRecord/); assert.match(deploymentPage, /features\.program-projects\.historyRemoveConfirm/); assert.match(deploymentPage, /buildProgramDeploymentReceiptJson\(/); -const backendMain = fs.readFileSync(path.join(uiRoot, "backend", "main.rs"), "utf8"); +const backendMain = fs.readFileSync( + path.join(repoRoot, "crates", "desktop-api", "src", "main.rs"), + "utf8", +); assert.match(backendMain, /route\("\/api\/program\/upgrade", post\(upgrade_generic_program\)\)/); assert.match(backendMain, /struct UpgradeProgramRequest/); assert.match(backendMain, /#\[serde\(deny_unknown_fields\)\]\s*struct UpgradeProgramRequest/); @@ -166,7 +168,7 @@ for (const key of tooltipKeys) { } const outputDirectory = fs.mkdtempSync( - path.join(os.tmpdir(), "sol-safekey-program-deploy-"), + path.join(os.tmpdir(), "fnzero-safe-program-deploy-"), ); try { diff --git a/ui/src-tauri/.gitignore b/apps/desktop/src-tauri/.gitignore similarity index 100% rename from ui/src-tauri/.gitignore rename to apps/desktop/src-tauri/.gitignore diff --git a/ui/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml similarity index 66% rename from ui/src-tauri/Cargo.toml rename to apps/desktop/src-tauri/Cargo.toml index f472905..9f3f9b8 100644 --- a/ui/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "app" +name = "fnzero-safe-desktop" version = "0.4.0" -description = "Sol SafeKey UI — Tauri desktop shell" +description = "FnzeroSafe — Tauri desktop shell" authors = ["you"] license = "MIT" repository = "" @@ -14,6 +14,10 @@ rust-version = "1.88" name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[[bin]] +name = "FnzeroSafe" +path = "src/main.rs" + [build-dependencies] tauri-build = { version = "=2.5.6", features = [] } @@ -21,7 +25,7 @@ tauri-build = { version = "=2.5.6", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "=2.10.3", features = [] } +tauri = { version = "=2.10.3", features = ["unstable"] } tauri-plugin-log = "2" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } aes-gcm = "0.10" @@ -31,3 +35,10 @@ pem-rfc7468 = { version = "0.7", features = ["std"] } rand = "0.8" zeroize = "1" rfd = "0.15" + +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6.2" +objc2 = "0.6.4" +objc2-foundation = "0.3.2" +objc2-local-authentication = "0.3.2" +security-framework = { version = "3.5", features = ["OSX_10_13"] } diff --git a/ui/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs similarity index 100% rename from ui/src-tauri/build.rs rename to apps/desktop/src-tauri/build.rs diff --git a/ui/src-tauri/capabilities/dapp.json b/apps/desktop/src-tauri/capabilities/dapp.json similarity index 55% rename from ui/src-tauri/capabilities/dapp.json rename to apps/desktop/src-tauri/capabilities/dapp.json index 0f320be..3b63302 100644 --- a/ui/src-tauri/capabilities/dapp.json +++ b/apps/desktop/src-tauri/capabilities/dapp.json @@ -2,8 +2,8 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "dapp", "description": "Narrow bridge permissions for allowlisted Solana DApp webviews.", - "windows": [ - "dapp" + "webviews": [ + "dapp-tab-*" ], "local": false, "remote": { @@ -15,7 +15,19 @@ "https://raydium.io/*", "https://*.raydium.io/*", "https://app.meteora.ag/*", - "https://*.meteora.ag/*" + "https://*.meteora.ag/*", + "https://orca.so/*", + "https://*.orca.so/*", + "https://drift.trade/*", + "https://*.drift.trade/*", + "https://kamino.finance/*", + "https://*.kamino.finance/*", + "https://tensor.trade/*", + "https://*.tensor.trade/*", + "https://magiceden.io/*", + "https://*.magiceden.io/*", + "https://sanctum.so/*", + "https://*.sanctum.so/*" ] }, "permissions": [ diff --git a/ui/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json similarity index 56% rename from ui/src-tauri/capabilities/default.json rename to apps/desktop/src-tauri/capabilities/default.json index c3e9ad8..57be98e 100644 --- a/ui/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -2,15 +2,22 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": [ + "webviews": [ "main" ], "permissions": [ "core:default", "allow-proxy-api-request", "allow-open-external-url", - "allow-open-dapp-window", + "allow-open-dapp-tab", + "allow-navigate-dapp-tab", + "allow-set-active-dapp-tab", + "allow-close-dapp-tab", "allow-resolve-dapp-sign-request", + "allow-biometric-wallet-status", + "allow-biometric-wallet-store-password", + "allow-biometric-wallet-get-password", + "allow-biometric-wallet-delete-password", "allow-pick-source-directory", "allow-save-download-file", "allow-open-download-file-location" diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..818ee61 Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..9f74d45 Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..49f8397 Binary files /dev/null and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..ddb103d Binary files /dev/null and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..c645a17 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..50cc830 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..b9e6272 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..eb879c4 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..9949798 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..208628b Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..b35e7fa Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..82df855 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..6f95fd7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..c4fd960 Binary files /dev/null and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/ui/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from ui/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml rename to apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c22427d Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8fb03ff Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..c21cb12 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..802e377 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4fc4dec Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ca1cd8b Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..f3be969 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7c9d2e7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4985dd2 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ebe8dc8 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..22a2376 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..f7d37d7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..549714d Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..067aa2c Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..ce6b3f1 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/ui/src-tauri/icons/android/values/ic_launcher_background.xml b/apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml similarity index 100% rename from ui/src-tauri/icons/android/values/ic_launcher_background.xml rename to apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml diff --git a/apps/desktop/src-tauri/icons/dapp-wallet-icon.png b/apps/desktop/src-tauri/icons/dapp-wallet-icon.png new file mode 100644 index 0000000..3252482 Binary files /dev/null and b/apps/desktop/src-tauri/icons/dapp-wallet-icon.png differ diff --git a/ui/src-tauri/icons/icon-preview.png b/apps/desktop/src-tauri/icons/icon-preview.png similarity index 100% rename from ui/src-tauri/icons/icon-preview.png rename to apps/desktop/src-tauri/icons/icon-preview.png diff --git a/apps/desktop/src-tauri/icons/icon-source.png b/apps/desktop/src-tauri/icons/icon-source.png new file mode 100644 index 0000000..8f47491 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon-source.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..82303a1 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..b36424f Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..db75b91 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..5e0f5a5 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..faa7906 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..faa7906 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..e3e6b9a Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..ebfd4f0 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..e593345 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..e593345 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..adbb054 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..faa7906 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..9ceccee Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..9ceccee Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..8dd0175 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..fa454d5 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..8dd0175 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..dd92c5e Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..7cba591 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..29f04cc Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..a00bebb Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/desktop/src-tauri/permissions/biometric_wallet.toml b/apps/desktop/src-tauri/permissions/biometric_wallet.toml new file mode 100644 index 0000000..2a1615a --- /dev/null +++ b/apps/desktop/src-tauri/permissions/biometric_wallet.toml @@ -0,0 +1,19 @@ +[[permission]] +identifier = "allow-biometric-wallet-status" +description = "Allow the main wallet UI to check whether a saved wallet has a Touch ID-protected password." +commands.allow = ["biometric_wallet_status"] + +[[permission]] +identifier = "allow-biometric-wallet-store-password" +description = "Allow the main wallet UI to store a saved wallet password in macOS Keychain with Touch ID protection." +commands.allow = ["biometric_wallet_store_password"] + +[[permission]] +identifier = "allow-biometric-wallet-get-password" +description = "Allow the main wallet UI to retrieve a saved wallet password after macOS Touch ID authentication." +commands.allow = ["biometric_wallet_get_password"] + +[[permission]] +identifier = "allow-biometric-wallet-delete-password" +description = "Allow the main wallet UI to remove a saved wallet Touch ID password from macOS Keychain." +commands.allow = ["biometric_wallet_delete_password"] diff --git a/apps/desktop/src-tauri/permissions/dapp.toml b/apps/desktop/src-tauri/permissions/dapp.toml new file mode 100644 index 0000000..cd334f5 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/dapp.toml @@ -0,0 +1,34 @@ +[[permission]] +identifier = "allow-open-dapp-tab" +description = "Open an embedded Solana DApp browser tab." +commands.allow = ["dapp_open_tab"] + +[[permission]] +identifier = "allow-navigate-dapp-tab" +description = "Navigate an embedded Solana DApp browser tab." +commands.allow = ["dapp_navigate_tab"] + +[[permission]] +identifier = "allow-set-active-dapp-tab" +description = "Show and position the active embedded Solana DApp browser tab." +commands.allow = ["dapp_set_active_tab"] + +[[permission]] +identifier = "allow-close-dapp-tab" +description = "Close an embedded Solana DApp browser tab." +commands.allow = ["dapp_close_tab"] + +[[permission]] +identifier = "allow-submit-dapp-sign-request" +description = "Allow an allowlisted DApp webview to submit a transaction signing request." +commands.allow = ["dapp_submit_sign_request"] + +[[permission]] +identifier = "allow-poll-dapp-sign-request" +description = "Allow an allowlisted DApp webview to poll its transaction signing result." +commands.allow = ["dapp_poll_sign_request"] + +[[permission]] +identifier = "allow-resolve-dapp-sign-request" +description = "Allow the main FnzeroSafe window to approve or reject a pending DApp signing request." +commands.allow = ["resolve_dapp_sign_request"] diff --git a/ui/src-tauri/permissions/downloads.toml b/apps/desktop/src-tauri/permissions/downloads.toml similarity index 100% rename from ui/src-tauri/permissions/downloads.toml rename to apps/desktop/src-tauri/permissions/downloads.toml diff --git a/ui/src-tauri/permissions/open_external.toml b/apps/desktop/src-tauri/permissions/open_external.toml similarity index 100% rename from ui/src-tauri/permissions/open_external.toml rename to apps/desktop/src-tauri/permissions/open_external.toml diff --git a/apps/desktop/src-tauri/permissions/proxy.toml b/apps/desktop/src-tauri/permissions/proxy.toml new file mode 100644 index 0000000..4ba4edf --- /dev/null +++ b/apps/desktop/src-tauri/permissions/proxy.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-proxy-api-request" +description = "Proxy HTTP calls from the desktop app to the local FnzeroSafe API (reqwest in Rust)." +commands.allow = ["proxy_api_request"] diff --git a/ui/src-tauri/permissions/source_directory.toml b/apps/desktop/src-tauri/permissions/source_directory.toml similarity index 100% rename from ui/src-tauri/permissions/source_directory.toml rename to apps/desktop/src-tauri/permissions/source_directory.toml diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..fe39576 --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,2069 @@ +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use aws_lc_rs::rsa::{OaepPublicEncryptingKey, PublicEncryptingKey, OAEP_SHA256_MGF1SHA256}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{ + Emitter, LogicalPosition, LogicalSize, Manager, Position, Rect, Size, WebviewBuilder, + WebviewUrl, +}; +use zeroize::Zeroizing; + +/// Must match `DEFAULT_API_PORT` in `src/lib/api.ts` +const FNZERO_SAFE_API_PORT: u16 = 3841; +const MAX_PROXY_BODY_BYTES: usize = 12 * 1024 * 1024; +const MAX_DOWNLOAD_FILE_BYTES: usize = 4 * 1024 * 1024; +const MAX_SECURE_PUBLIC_KEY_PEM_BYTES: usize = 2 * 1024; +const PROGRAM_DEPLOY_PROXY_TIMEOUT_SECS: u64 = 60 * 60; +const SECURE_BODY_HEADER: &str = "x-fnzero-safe-secure-body"; +const SECURE_BODY_VERSION: &str = "1"; +const DAPP_TAB_LABEL_PREFIX: &str = "dapp-tab-"; +const DAPP_SIGN_REQUEST_EVENT: &str = "dapp://sign-request"; +const DAPP_TAB_URL_EVENT: &str = "dapp://tab-url"; +const DAPP_TAB_TITLE_EVENT: &str = "dapp://tab-title"; +const DAPP_NEW_WINDOW_EVENT: &str = "dapp://new-window"; +const DAPP_REQUEST_TTL_MS: u64 = 3 * 60 * 1000; +const DAPP_WALLET_NAME: &str = "FnzeroSafe"; +#[cfg(target_os = "macos")] +const BIOMETRIC_WALLET_PASSWORD_SERVICE: &str = "dev.fnzero-safe.wallet.password.v3"; + +#[derive(Clone)] +struct AllowedDapp { + id: &'static str, + name: &'static str, +} + +#[derive(Clone)] +struct DappSession { + app_id: String, + app_name: String, + url: String, + wallet_public_key: String, + network: String, + opened_at_ms: u64, +} + +#[derive(Clone, Serialize)] +struct DappSignRequestEvent { + request_id: String, + app_id: String, + app_name: String, + app_url: String, + method: String, + wallet_public_key: String, + network: String, + transaction_base64: String, + transaction_format: String, + #[serde(skip_serializing_if = "Option::is_none")] + message_base64: Option, + created_at_ms: u64, +} + +#[derive(Clone)] +struct DappPendingRequest { + webview_label: String, + event: DappSignRequestEvent, + result: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +struct DappSignResult { + approved: bool, + #[serde(default)] + error: Option, + #[serde(default)] + signature: Option, + #[serde(default)] + raw_transaction: Option, + #[serde(default)] + recent_blockhash: Option, +} + +#[derive(Serialize)] +struct DappPollResponse { + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, +} + +#[derive(Default)] +struct DappBridgeState { + sessions: Mutex>, + requests: Mutex>, +} + +#[derive(Clone, Serialize)] +struct DappTabUrlEvent { + tab_id: String, + url: String, + loaded: bool, +} + +#[derive(Clone, Serialize)] +struct DappTabTitleEvent { + tab_id: String, + title: String, +} + +#[derive(Clone, Serialize)] +struct DappNewWindowEvent { + source_tab_id: String, + url: String, +} + +#[derive(Serialize)] +struct ProxyResponse { + status: u16, + body: String, +} + +#[derive(Deserialize)] +struct ProxyRequestHeader { + name: String, + value: String, +} + +#[derive(Deserialize)] +struct SecureSessionResponse { + version: String, + public_key_pem: String, + api_token: Option, +} + +#[derive(Deserialize)] +struct BiometricWalletRequest { + wallet_id: String, + public_key: String, +} + +#[derive(Deserialize)] +struct BiometricWalletStoreRequest { + wallet_id: String, + public_key: String, + password: String, +} + +#[derive(Serialize)] +struct BiometricWalletStatus { + supported: bool, + configured: bool, + reason: Option, +} + +fn biometric_wallet_account(wallet_id: &str, public_key: &str) -> Result { + let wallet_id = wallet_id.trim(); + let public_key = public_key.trim(); + if wallet_id.len() != 32 || !wallet_id.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("invalid wallet id".to_string()); + } + if !is_likely_solana_pubkey(public_key) { + return Err("invalid wallet public key".to_string()); + } + Ok(format!("{wallet_id}:{public_key}")) +} + +#[cfg(target_os = "macos")] +fn biometric_error_message(error: security_framework::base::Error) -> String { + let code = error.code(); + match code { + -128 => "Touch ID 已取消".to_string(), + -25300 => "还没有为这个钱包启用 Touch ID".to_string(), + -25293 => "Touch ID 验证失败或无权读取 Keychain 凭据".to_string(), + _ => format!("macOS Keychain 错误: {code}"), + } +} + +#[cfg(target_os = "macos")] +fn biometric_local_auth_error_message(code: objc2_foundation::NSInteger) -> String { + match code { + -1 => "Touch ID 验证失败".to_string(), + -2 => "Touch ID 已取消".to_string(), + -3 => "Touch ID 已切换到密码输入".to_string(), + -4 => "Touch ID 被系统中断".to_string(), + -5 => "macOS 未设置登录密码,无法使用 Touch ID".to_string(), + -6 => "这台 Mac 不支持 Touch ID".to_string(), + -7 => "还没有在 macOS 中录入 Touch ID 指纹".to_string(), + -8 => "Touch ID 已锁定,请先用系统密码解锁 Touch ID".to_string(), + -9 => "Touch ID 验证已被应用取消".to_string(), + -10 => "Touch ID 验证上下文已失效".to_string(), + -1004 => "Touch ID 当前不允许弹出交互窗口".to_string(), + _ => format!("macOS Touch ID 错误: {code}"), + } +} + +#[cfg(target_os = "macos")] +fn biometric_touch_id_available() -> Result<(), String> { + use objc2_local_authentication::{LAContext, LAPolicy}; + + let context = unsafe { LAContext::new() }; + unsafe { + context + .canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics) + .map_err(|error| biometric_local_auth_error_message(error.code())) + } +} + +#[cfg(target_os = "macos")] +mod biometric_wallet_keychain { + use super::{biometric_error_message, BIOMETRIC_WALLET_PASSWORD_SERVICE}; + use security_framework::{ + access_control::{ProtectionMode, SecAccessControl}, + item::{ItemClass, ItemSearchOptions}, + passwords::{ + delete_generic_password, generic_password, set_generic_password_options, + AccessControlOptions, PasswordOptions, + }, + }; + + const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; + const LEGACY_SERVICES: &[&str] = &[ + "dev.sol-safekey.fnzero-wallet.password.v1", + "dev.sol-safekey.fnzero-wallet.password.v2", + "dev.sol-safekey.fnzero-wallet.password.v3", + ]; + + pub fn configured(account: &str) -> Result { + let mut query = ItemSearchOptions::new(); + query + .class(ItemClass::generic_password()) + .service(BIOMETRIC_WALLET_PASSWORD_SERVICE) + .account(account) + .load_attributes(true); + match query.search() { + Ok(items) => Ok(!items.is_empty()), + Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(false), + Err(error) => Err(biometric_error_message(error)), + } + } + + pub fn store(account: &str, password: &str) -> Result<(), String> { + let _ = delete_generic_password(BIOMETRIC_WALLET_PASSWORD_SERVICE, account); + let access_control = SecAccessControl::create_with_protection( + Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly), + AccessControlOptions::BIOMETRY_CURRENT_SET.bits(), + ) + .map_err(biometric_error_message)?; + let mut options = + PasswordOptions::new_generic_password(BIOMETRIC_WALLET_PASSWORD_SERVICE, account); + options.set_access_control(access_control); + set_generic_password_options(password.as_bytes(), options) + .map_err(biometric_error_message)?; + delete_legacy(account) + } + + fn delete_legacy(account: &str) -> Result<(), String> { + for service in LEGACY_SERVICES { + match delete_generic_password(service, account) { + Ok(()) => {} + Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => {} + Err(error) => return Err(biometric_error_message(error)), + } + } + Ok(()) + } + + pub fn load(account: &str) -> Result { + let options = + PasswordOptions::new_generic_password(BIOMETRIC_WALLET_PASSWORD_SERVICE, account); + match generic_password(options) { + Ok(password) => String::from_utf8(password) + .map_err(|_| "Keychain 凭据不是有效的 UTF-8 钱包密码".to_string()), + Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => { + Err("还没有为这个钱包启用 Touch ID".to_string()) + } + Err(error) => Err(biometric_error_message(error)), + } + } + + pub fn delete(account: &str) -> Result<(), String> { + for service in std::iter::once(BIOMETRIC_WALLET_PASSWORD_SERVICE) + .chain(LEGACY_SERVICES.iter().copied()) + { + match delete_generic_password(service, account) { + Ok(()) => {} + Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => {} + Err(error) => return Err(biometric_error_message(error)), + } + } + Ok(()) + } +} + +#[tauri::command] +fn biometric_wallet_status(req: BiometricWalletRequest) -> Result { + let account = biometric_wallet_account(&req.wallet_id, &req.public_key)?; + #[cfg(target_os = "macos")] + { + let supported = biometric_touch_id_available(); + let configured = biometric_wallet_keychain::configured(&account)?; + Ok(BiometricWalletStatus { + supported: supported.is_ok(), + configured: configured && supported.is_ok(), + reason: supported.err(), + }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = account; + Ok(BiometricWalletStatus { + supported: false, + configured: false, + reason: Some("Touch ID 只支持 macOS 桌面客户端".to_string()), + }) + } +} + +#[tauri::command] +fn biometric_wallet_store_password(req: BiometricWalletStoreRequest) -> Result<(), String> { + let account = biometric_wallet_account(&req.wallet_id, &req.public_key)?; + if req.password.is_empty() { + return Err("wallet password is required".to_string()); + } + #[cfg(target_os = "macos")] + { + biometric_wallet_keychain::store(&account, &req.password) + } + #[cfg(not(target_os = "macos"))] + { + let _ = account; + Err("Touch ID 只支持 macOS 桌面客户端".to_string()) + } +} + +#[tauri::command] +fn biometric_wallet_get_password(req: BiometricWalletRequest) -> Result { + let account = biometric_wallet_account(&req.wallet_id, &req.public_key)?; + #[cfg(target_os = "macos")] + { + biometric_wallet_keychain::load(&account) + } + #[cfg(not(target_os = "macos"))] + { + let _ = account; + Err("Touch ID 只支持 macOS 桌面客户端".to_string()) + } +} + +#[tauri::command] +fn biometric_wallet_delete_password(req: BiometricWalletRequest) -> Result<(), String> { + let account = biometric_wallet_account(&req.wallet_id, &req.public_key)?; + #[cfg(target_os = "macos")] + { + biometric_wallet_keychain::delete(&account) + } + #[cfg(not(target_os = "macos"))] + { + let _ = account; + Ok(()) + } +} + +fn encrypt_secure_body(body: &str, public_key_pem: &str) -> Result { + if public_key_pem.len() > MAX_SECURE_PUBLIC_KEY_PEM_BYTES { + return Err("invalid secure API public key: PEM is too large".to_string()); + } + let mut public_key_der = [0_u8; 1024]; + let (label, public_key_der) = + pem_rfc7468::decode(public_key_pem.as_bytes(), &mut public_key_der) + .map_err(|e| format!("invalid secure API public key PEM: {}", e))?; + if label != "PUBLIC KEY" { + return Err("invalid secure API public key PEM label".to_string()); + } + let public_key = PublicEncryptingKey::from_der(public_key_der) + .map_err(|e| format!("invalid secure API public key: {}", e))?; + if public_key.key_size_bits() != 2048 { + return Err("invalid secure API public key size".to_string()); + } + let public_key = OaepPublicEncryptingKey::new(public_key) + .map_err(|_| "failed to initialize secure API public key".to_string())?; + let mut rng = OsRng; + let mut aes_key = Zeroizing::new([0_u8; 32]); + rng.fill_bytes(&mut *aes_key); + let mut iv = [0_u8; 12]; + rng.fill_bytes(&mut iv); + let cipher = Aes256Gcm::new_from_slice(&*aes_key) + .map_err(|_| "failed to initialize request encryption")?; + let ciphertext = cipher + .encrypt(Nonce::from_slice(&iv), body.as_bytes()) + .map_err(|_| "failed to encrypt request body")?; + let mut encrypted_key = vec![0_u8; public_key.ciphertext_size()]; + let encrypted_key_len = public_key + .encrypt(&OAEP_SHA256_MGF1SHA256, &*aes_key, &mut encrypted_key, None) + .map_err(|_| "failed to encrypt request key".to_string())? + .len(); + encrypted_key.truncate(encrypted_key_len); + + Ok(json!({ + "version": 1, + "encrypted_key": BASE64.encode(encrypted_key), + "iv": BASE64.encode(iv), + "ciphertext": BASE64.encode(ciphertext), + }) + .to_string()) +} + +fn proxied_api_path_requires_token(path: &str) -> bool { + !matches!(path.trim_matches('/'), "health" | "secure/session") +} + +async fn fetch_secure_session(client: &reqwest::Client) -> Result { + let session_url = format!( + "http://127.0.0.1:{}/api/secure/session", + FNZERO_SAFE_API_PORT + ); + let session_resp = client + .get(session_url) + .send() + .await + .map_err(|e| format!("failed to initialize secure API session: {}", e))?; + if !session_resp.status().is_success() { + return Err(format!( + "failed to initialize secure API session: HTTP {}", + session_resp.status().as_u16() + )); + } + let session = session_resp + .json::() + .await + .map_err(|e| format!("invalid secure API session: {}", e))?; + if session.version != SECURE_BODY_VERSION { + return Err("unsupported secure API session version".to_string()); + } + Ok(session) +} + +/// HTTP from Rust → avoids WKWebView `fetch` URL issues with localhost /api. +#[tauri::command] +async fn proxy_api_request( + method: String, + path: String, + headers: Option>, + body: Option, + secure_proxy: Option, +) -> Result { + let path = path.trim_start_matches('/'); + if path.is_empty() + || path.contains("://") + || path.contains('\\') + || path.split('/').any(|part| part == "..") + || !path + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.')) + { + return Err("invalid API path".to_string()); + } + if let Some(b) = body.as_ref() { + if b.len() > MAX_PROXY_BODY_BYTES { + return Err("request body too large".to_string()); + } + } + let url = format!("http://127.0.0.1:{}/api/{}", FNZERO_SAFE_API_PORT, path); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs( + PROGRAM_DEPLOY_PROXY_TIMEOUT_SECS, + )) + .build() + .map_err(|e| e.to_string())?; + + let method_upper = method.to_uppercase(); + let secure_proxy = secure_proxy.unwrap_or(false); + let mut req = match method_upper.as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + _ => return Err(format!("unsupported HTTP method: {}", method)), + }; + + req = req.header("Content-Type", "application/json"); + req = req.header("Origin", "tauri://localhost"); + if let Some(headers) = headers { + for header in headers { + let name = header.name.to_ascii_lowercase(); + if matches!(name.as_str(), "content-type" | SECURE_BODY_HEADER) { + req = req.header(name, header.value); + } + } + } + let mut session = None; + if proxied_api_path_requires_token(path) { + let mut token_attached = false; + if let Ok(token) = std::env::var("FNZERO_SAFE_API_TOKEN") + .or_else(|_| std::env::var("SOL_SAFEKEY_API_TOKEN")) + { + let token = token.trim().to_string(); + if !token.is_empty() { + req = req.header("X-Fnzero-Safe-Token", token); + token_attached = true; + } + } + if !token_attached { + session = Some(fetch_secure_session(&client).await?); + let token = session + .as_ref() + .and_then(|session| session.api_token.as_deref()) + .ok_or_else(|| { + "secure API session did not provide a local API token".to_string() + })?; + req = req.header("X-Fnzero-Safe-Token", token); + } + } + if let Some(b) = body { + if secure_proxy && matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH") { + let b = Zeroizing::new(b); + if session.is_none() { + session = Some(fetch_secure_session(&client).await?); + } + let session = session.as_ref().expect("secure session is initialized"); + let encrypted_body = encrypt_secure_body(&b, &session.public_key_pem)?; + req = req.header(SECURE_BODY_HEADER, SECURE_BODY_VERSION); + req = req.body(encrypted_body); + } else { + req = req.body(b); + } + } + + let resp = req.send().await.map_err(|e| e.to_string())?; + let status = resp.status().as_u16(); + let body = resp.text().await.map_err(|e| e.to_string())?; + + Ok(ProxyResponse { status, body }) +} + +fn is_allowed_external_https_url(url: &str) -> bool { + let trimmed = url.trim(); + if !trimmed.starts_with("https://") || trimmed.len() > 2048 { + return false; + } + if trimmed.chars().any(|c| c.is_control() || c.is_whitespace()) { + return false; + } + let Some(rest) = trimmed.strip_prefix("https://") else { + return false; + }; + let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); + if host.is_empty() || host.starts_with('.') || host.ends_with('.') || host.contains('@') { + return false; + } + true +} + +fn spawn_system_browser(url: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(url) + .spawn() + .map(|_| ()) + .map_err(|error| format!("failed to open external browser: {error}")) + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .map(|_| ()) + .map_err(|error| format!("failed to open external browser: {error}")) + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + std::process::Command::new("xdg-open") + .arg(url) + .spawn() + .map(|_| ()) + .map_err(|error| format!("failed to open external browser: {error}")) + } +} + +fn reveal_file_in_system_file_manager(path: &Path) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg("-R") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|error| format!("failed to reveal download file: {error}")) + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("explorer") + .arg(format!("/select,{}", path.to_string_lossy())) + .spawn() + .map(|_| ()) + .map_err(|error| format!("failed to reveal download file: {error}")) + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + let directory = path.parent().unwrap_or_else(|| Path::new("/")); + std::process::Command::new("xdg-open") + .arg(directory) + .spawn() + .map(|_| ()) + .map_err(|error| format!("failed to open download directory: {error}")) + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or_default() +} + +fn allowed_dapp(app_id: &str) -> Option { + match app_id.trim().to_ascii_lowercase().as_str() { + "jupiter" => Some(AllowedDapp { + id: "jupiter", + name: "Jupiter", + }), + "pumpfun" => Some(AllowedDapp { + id: "pumpfun", + name: "pump.fun", + }), + "raydium" => Some(AllowedDapp { + id: "raydium", + name: "Raydium", + }), + "meteora" => Some(AllowedDapp { + id: "meteora", + name: "Meteora", + }), + "orca" => Some(AllowedDapp { + id: "orca", + name: "Orca", + }), + "drift" => Some(AllowedDapp { + id: "drift", + name: "Drift", + }), + "kamino" => Some(AllowedDapp { + id: "kamino", + name: "Kamino", + }), + "tensor" => Some(AllowedDapp { + id: "tensor", + name: "Tensor", + }), + "magiceden" => Some(AllowedDapp { + id: "magiceden", + name: "Magic Eden", + }), + "sanctum" => Some(AllowedDapp { + id: "sanctum", + name: "Sanctum", + }), + _ => None, + } +} + +fn dapp_tab_label(tab_id: &str) -> Result { + let trimmed = tab_id.trim(); + if trimmed.is_empty() + || trimmed.len() > 64 + || !trimmed + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + return Err("invalid dapp tab id".to_string()); + } + Ok(format!("{DAPP_TAB_LABEL_PREFIX}{trimmed}")) +} + +fn dapp_tab_id_from_label(label: &str) -> Option { + label + .strip_prefix(DAPP_TAB_LABEL_PREFIX) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn dapp_browser_data_directory(app: &tauri::AppHandle) -> Result { + let mut data_dir = app + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data directory: {error}"))?; + data_dir.push("dapp-browser-profile"); + std::fs::create_dir_all(&data_dir) + .map_err(|error| format!("failed to create dapp browser profile directory: {error}"))?; + Ok(data_dir) +} + +fn is_safe_browser_url(url: &tauri::Url) -> bool { + match url.scheme() { + "https" => url.host_str().is_some_and(|host| { + !host.is_empty() + && !host.starts_with('.') + && !host.ends_with('.') + && !host.contains('@') + }), + "http" => url + .host_str() + .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "::1")), + _ => false, + } +} + +fn is_safe_dapp_webview_navigation_url(url: &tauri::Url) -> bool { + if is_safe_browser_url(url) { + return true; + } + + matches!(url.scheme(), "about" | "blob" | "data") +} + +fn is_allowed_connected_dapp_navigation_url(dapp: &AllowedDapp, url: &tauri::Url) -> bool { + is_allowed_dapp_url(dapp, url) || matches!(url.scheme(), "about" | "blob") +} + +fn parse_dapp_browser_url(raw_url: &str) -> Result { + let trimmed = raw_url.trim(); + if trimmed.is_empty() || trimmed.len() > 2048 { + return Err("invalid dapp URL".to_string()); + } + if trimmed.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("invalid dapp URL".to_string()); + } + let url = trimmed + .parse::() + .map_err(|error| format!("invalid dapp URL: {error}"))?; + if !is_safe_browser_url(&url) { + return Err( + "only https URLs or localhost http URLs can be opened in DApp tabs".to_string(), + ); + } + Ok(url) +} + +fn host_matches_domain(host: &str, domain: &str) -> bool { + host == domain || host.ends_with(&format!(".{domain}")) +} + +fn is_allowed_dapp_url(dapp: &AllowedDapp, url: &tauri::Url) -> bool { + if url.scheme() != "https" { + return false; + } + let Some(host) = url.host_str().map(|value| value.to_ascii_lowercase()) else { + return false; + }; + match dapp.id { + "jupiter" => host_matches_domain(&host, "jup.ag"), + "pumpfun" => host_matches_domain(&host, "pump.fun"), + "raydium" => host_matches_domain(&host, "raydium.io"), + "meteora" => host_matches_domain(&host, "meteora.ag"), + "orca" => host_matches_domain(&host, "orca.so"), + "drift" => host_matches_domain(&host, "drift.trade"), + "kamino" => host_matches_domain(&host, "kamino.finance"), + "tensor" => host_matches_domain(&host, "tensor.trade"), + "magiceden" => host_matches_domain(&host, "magiceden.io"), + "sanctum" => host_matches_domain(&host, "sanctum.so"), + _ => false, + } +} + +fn dapp_webview_bounds(x: f64, y: f64, width: f64, height: f64) -> Result { + if !(width.is_finite() && height.is_finite() && x.is_finite() && y.is_finite()) + || width < 40.0 + || height < 40.0 + || width > 10000.0 + || height > 10000.0 + { + return Err("invalid dapp tab bounds".to_string()); + } + Ok(Rect { + position: Position::Logical(LogicalPosition::new(x, y)), + size: Size::Logical(LogicalSize::new(width, height)), + }) +} + +fn is_likely_solana_pubkey(value: &str) -> bool { + let trimmed = value.trim(); + (32..=44).contains(&trimmed.len()) + && trimmed + .bytes() + .all(|b| matches!(b, b'1'..=b'9' | b'A'..=b'H' | b'J'..=b'N' | b'P'..=b'Z' | b'a'..=b'k' | b'm'..=b'z')) +} + +fn validate_dapp_method(method: &str) -> Result { + let normalized = method.trim(); + match normalized { + "signTransaction" + | "signAllTransactions" + | "signAndSendTransaction" + | "sendTransaction" + | "signMessage" => Ok(normalized.to_string()), + _ => Err("unsupported dapp signing method".to_string()), + } +} + +fn validate_transaction_format(format: &str) -> Result { + let normalized = format.trim().to_ascii_lowercase(); + match normalized.as_str() { + "legacy" | "versioned" | "v0" | "auto" => Ok(normalized), + _ => Err("unsupported transaction format".to_string()), + } +} + +fn validate_dapp_transaction_base64(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.len() > 4096 { + return Err("invalid transaction payload".to_string()); + } + if BASE64.decode(trimmed).is_err() { + return Err("transaction payload is not valid base64".to_string()); + } + Ok(trimmed.to_string()) +} + +fn validate_dapp_message_base64(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.len() > 24 * 1024 { + return Err("invalid message payload".to_string()); + } + let bytes = BASE64 + .decode(trimmed) + .map_err(|_| "message payload is not valid base64".to_string())?; + if bytes.is_empty() || bytes.len() > 16 * 1024 { + return Err("invalid message payload".to_string()); + } + Ok(trimmed.to_string()) +} + +fn dapp_request_id() -> String { + let mut random = [0_u8; 8]; + OsRng.fill_bytes(&mut random); + format!("dapp-{}-{}", now_ms(), BASE64.encode(random)).replace(['+', '/', '='], "") +} + +fn dapp_provider_script( + dapp: &AllowedDapp, + wallet_public_key: &str, + network: &str, +) -> Result { + let wallet_public_key = serde_json::to_string(wallet_public_key).map_err(|e| e.to_string())?; + let network = serde_json::to_string(network).map_err(|e| e.to_string())?; + let app_id = serde_json::to_string(dapp.id).map_err(|e| e.to_string())?; + let app_name = serde_json::to_string(dapp.name).map_err(|e| e.to_string())?; + let wallet_name = serde_json::to_string(DAPP_WALLET_NAME).map_err(|e| e.to_string())?; + let wallet_icon = serde_json::to_string(&format!( + "data:image/png;base64,{}", + BASE64.encode(include_bytes!("../icons/dapp-wallet-icon.png")) + )) + .map_err(|e| e.to_string())?; + Ok(format!( + r#" +(function () {{ + const walletPublicKey = {wallet_public_key}; + const network = {network}; + const appId = {app_id}; + const appName = {app_name}; + const walletName = {wallet_name}; + const walletIcon = {wallet_icon}; + const listeners = new Map(); + let connected = true; + + function sleep(ms) {{ return new Promise((resolve) => setTimeout(resolve, ms)); }} + function tauriInvoke(command, args) {{ + const invoke = window.__TAURI__?.core?.invoke || window.__TAURI_INTERNALS__?.invoke; + if (typeof invoke !== "function") {{ + throw new Error("FnzeroSafe bridge is unavailable"); + }} + return invoke(command, args || {{}}); + }} + function bytesToBase64(bytes) {{ + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ""; + for (let i = 0; i < view.length; i += 0x8000) {{ + binary += String.fromCharCode.apply(null, Array.from(view.subarray(i, i + 0x8000))); + }} + return btoa(binary); + }} + function base64ToBytes(base64) {{ + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; + }} + function base58Decode(value) {{ + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + const bytes = [0]; + for (const char of String(value)) {{ + const carryIndex = alphabet.indexOf(char); + if (carryIndex < 0) throw new Error("Invalid base58 value"); + let carry = carryIndex; + for (let i = 0; i < bytes.length; i += 1) {{ + carry += bytes[i] * 58; + bytes[i] = carry & 0xff; + carry >>= 8; + }} + while (carry > 0) {{ + bytes.push(carry & 0xff); + carry >>= 8; + }} + }} + for (const char of String(value)) {{ + if (char === "1") bytes.push(0); + else break; + }} + return new Uint8Array(bytes.reverse()); + }} + function setAutoConnectHints() {{ + try {{ + window.localStorage.setItem("walletName", walletName); + window.localStorage.setItem("solana-wallet-adapter-wallet", walletName); + window.localStorage.setItem("recentWallet", walletName); + window.localStorage.setItem("recentWalletName", walletName); + }} catch (_) {{}} + }} + function announceConnected() {{ + if (!connected) return; + setAutoConnectHints(); + try {{ emit("connect", publicKey); }} catch (_) {{}} + try {{ emitWallet("change", {{ accounts: standardWallet.accounts, features: standardWallet.features }}); }} catch (_) {{}} + try {{ window.dispatchEvent(new Event("solana#initialized")); }} catch (_) {{}} + }} + function transactionFormat(transaction) {{ + if (transaction && (transaction.version !== undefined || transaction.message?.addressTableLookups)) return "versioned"; + return "legacy"; + }} + function bytesView(value) {{ + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + return null; + }} + function serializeTransaction(transaction) {{ + if (!transaction || typeof transaction.serialize !== "function") {{ + throw new Error("Invalid Solana transaction"); + }} + try {{ + return transaction.serialize({{ requireAllSignatures: false, verifySignatures: false }}); + }} catch (_) {{ + return transaction.serialize(); + }} + }} + function transactionPayload(transaction) {{ + const view = bytesView(transaction); + if (view) return {{ base64: bytesToBase64(view), format: "auto" }}; + return {{ + base64: bytesToBase64(serializeTransaction(transaction)), + format: transactionFormat(transaction), + }}; + }} + function hydrateSignedTransaction(original, rawTransaction) {{ + const bytes = base64ToBytes(rawTransaction); + if (bytesView(original)) return bytes; + if (original?.constructor && typeof original.constructor.deserialize === "function") {{ + return original.constructor.deserialize(bytes); + }} + if (original?.constructor && typeof original.constructor.from === "function") {{ + return original.constructor.from(bytes); + }} + return bytes; + }} + async function requestSignature(method, transaction) {{ + const payload = transactionPayload(transaction); + const requestId = await tauriInvoke("dapp_submit_sign_request", {{ + method, + transactionBase64: payload.base64, + transactionFormat: payload.format, + }}); + const started = Date.now(); + while (Date.now() - started < 180000) {{ + const poll = await tauriInvoke("dapp_poll_sign_request", {{ requestId }}); + if (poll.status === "approved") return poll.result || {{}}; + if (poll.status === "rejected") throw new Error(poll.result?.error || "User rejected the request"); + if (poll.status === "expired") throw new Error("FnzeroSafe signing request expired"); + await sleep(500); + }} + throw new Error("FnzeroSafe signing request timed out"); + }} + async function requestMessageSignature(message) {{ + let messageBytes; + if (message instanceof Uint8Array) {{ + messageBytes = message; + }} else if (message instanceof ArrayBuffer) {{ + messageBytes = new Uint8Array(message); + }} else if (ArrayBuffer.isView(message)) {{ + messageBytes = new Uint8Array(message.buffer, message.byteOffset, message.byteLength); + }} else if (typeof message === "string") {{ + messageBytes = new TextEncoder().encode(message); + }} else {{ + throw new Error("Invalid Solana message"); + }} + const requestId = await tauriInvoke("dapp_submit_sign_request", {{ + method: "signMessage", + messageBase64: bytesToBase64(messageBytes), + }}); + const started = Date.now(); + while (Date.now() - started < 180000) {{ + const poll = await tauriInvoke("dapp_poll_sign_request", {{ requestId }}); + if (poll.status === "approved") return poll.result || {{}}; + if (poll.status === "rejected") throw new Error(poll.result?.error || "User rejected the request"); + if (poll.status === "expired") throw new Error("FnzeroSafe signing request expired"); + await sleep(500); + }} + throw new Error("FnzeroSafe signing request timed out"); + }} + function emit(event, value) {{ + const handlers = listeners.get(event); + if (!handlers) return; + handlers.forEach((handler) => {{ + try {{ handler(value); }} catch (_) {{}} + }}); + }} + const publicKey = {{ + toBase58: () => walletPublicKey, + toString: () => walletPublicKey, + toBytes: () => base58Decode(walletPublicKey), + toBuffer: () => base58Decode(walletPublicKey), + equals: (other) => String(other?.toBase58 ? other.toBase58() : other) === walletPublicKey, + }}; + const account = {{ + address: walletPublicKey, + publicKey: base58Decode(walletPublicKey), + chains: ["solana:mainnet", "solana:devnet", "solana:testnet"], + features: [ + "standard:connect", + "standard:disconnect", + "standard:events", + "solana:signTransaction", + "solana:signAndSendTransaction", + "solana:signMessage" + ], + label: walletName, + }}; + const walletListeners = new Map(); + function walletOn(event, handler) {{ + if (!walletListeners.has(event)) walletListeners.set(event, new Set()); + walletListeners.get(event).add(handler); + return () => walletListeners.get(event)?.delete(handler); + }} + function emitWallet(event, value) {{ + walletListeners.get(event)?.forEach((handler) => {{ + try {{ handler(value); }} catch (_) {{}} + }}); + }} + const provider = {{ + isPhantom: true, + isSolflare: true, + isSolSafeKey: true, + appId, + appName, + network, + get publicKey() {{ return connected ? publicKey : null; }}, + get isConnected() {{ return connected; }}, + async connect() {{ + connected = true; + emit("connect", publicKey); + emitWallet("change", {{ accounts: standardWallet.accounts }}); + return {{ publicKey }}; + }}, + async disconnect() {{ + connected = false; + emit("disconnect"); + emitWallet("change", {{ accounts: standardWallet.accounts }}); + }}, + on(event, handler) {{ + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event).add(handler); + return this; + }}, + off(event, handler) {{ + listeners.get(event)?.delete(handler); + return this; + }}, + removeListener(event, handler) {{ + return this.off(event, handler); + }}, + async request(args) {{ + const method = typeof args === "string" ? args : args?.method; + const params = typeof args === "string" ? undefined : args?.params; + if (method === "connect") return this.connect(params); + if (method === "disconnect") return this.disconnect(); + if (method === "signTransaction") return this.signTransaction(params?.transaction || params?.[0] || params); + if (method === "signAllTransactions") return this.signAllTransactions(params?.transactions || params?.[0] || params); + if (method === "signMessage") return this.signMessage(params?.message ?? params?.[0] ?? params); + if (method === "signAndSendTransaction") return this.signAndSendTransaction(params?.transaction || params?.[0] || params); + throw new Error("Unsupported FnzeroSafe provider method: " + method); + }}, + async signTransaction(transaction) {{ + const result = await requestSignature("signTransaction", transaction); + if (!result.raw_transaction) throw new Error("FnzeroSafe did not return a signed transaction"); + return hydrateSignedTransaction(transaction, result.raw_transaction); + }}, + async signAllTransactions(transactions) {{ + const signed = []; + for (const transaction of transactions || []) {{ + signed.push(await this.signTransaction(transaction)); + }} + return signed; + }}, + async signAndSendTransaction(input) {{ + const transaction = input?.transaction || input; + const result = await requestSignature("signAndSendTransaction", transaction); + if (!result.signature) throw new Error("FnzeroSafe did not return a transaction signature"); + return {{ signature: result.signature }}; + }}, + async sendTransaction(transaction, connection, options) {{ + if (connection && typeof connection.sendRawTransaction === "function") {{ + const signedTransaction = await this.signTransaction(transaction); + const raw = bytesView(signedTransaction) || serializeTransaction(signedTransaction); + return connection.sendRawTransaction(raw, options || {{}}); + }} + const result = await requestSignature("sendTransaction", transaction); + if (!result.signature) throw new Error("FnzeroSafe did not return a transaction signature"); + return result.signature; + }}, + async signMessage(message) {{ + const result = await requestMessageSignature(message); + if (!result.signature) throw new Error("FnzeroSafe did not return a message signature"); + return base58Decode(result.signature); + }}, + }}; + const standardWallet = {{ + version: "1.0.0", + name: walletName, + icon: walletIcon, + chains: ["solana:mainnet", "solana:devnet", "solana:testnet"], + get accounts() {{ return connected ? [account] : []; }}, + features: {{ + "standard:connect": {{ + version: "1.0.0", + connect: async () => {{ + connected = true; + emit("connect", publicKey); + emitWallet("change", {{ accounts: standardWallet.accounts }}); + return {{ accounts: standardWallet.accounts }}; + }}, + }}, + "standard:disconnect": {{ + version: "1.0.0", + disconnect: async () => {{ + connected = false; + emit("disconnect"); + emitWallet("change", {{ accounts: standardWallet.accounts }}); + }}, + }}, + "standard:events": {{ + version: "1.0.0", + on: walletOn, + }}, + "solana:signTransaction": {{ + version: "1.0.0", + supportedTransactionVersions: ["legacy", 0], + signTransaction: async (...inputs) => {{ + const signed = []; + for (const input of inputs) {{ + let resolved = false; + const payload = transactionPayload(input.transaction ?? input); + const requestId = await tauriInvoke("dapp_submit_sign_request", {{ + method: "signTransaction", + transactionBase64: payload.base64, + transactionFormat: payload.format, + }}); + const started = Date.now(); + while (Date.now() - started < 180000) {{ + const poll = await tauriInvoke("dapp_poll_sign_request", {{ requestId }}); + if (poll.status === "approved") {{ + if (!poll.result?.raw_transaction) throw new Error("FnzeroSafe did not return a signed transaction"); + signed.push({{ signedTransaction: base64ToBytes(poll.result.raw_transaction) }}); + resolved = true; + break; + }} + if (poll.status === "rejected") throw new Error(poll.result?.error || "User rejected the request"); + if (poll.status === "expired") throw new Error("FnzeroSafe signing request expired"); + await sleep(500); + }} + if (!resolved) throw new Error("FnzeroSafe signing request timed out"); + }} + return signed; + }}, + }}, + "solana:signAndSendTransaction": {{ + version: "1.0.0", + supportedTransactionVersions: ["legacy", 0], + signAndSendTransaction: async (...inputs) => {{ + const signed = []; + for (const input of inputs) {{ + let resolved = false; + const payload = transactionPayload(input.transaction ?? input); + const requestId = await tauriInvoke("dapp_submit_sign_request", {{ + method: "signAndSendTransaction", + transactionBase64: payload.base64, + transactionFormat: payload.format, + }}); + const started = Date.now(); + while (Date.now() - started < 180000) {{ + const poll = await tauriInvoke("dapp_poll_sign_request", {{ requestId }}); + if (poll.status === "approved") {{ + if (!poll.result?.signature) throw new Error("FnzeroSafe did not return a transaction signature"); + signed.push({{ signature: base58Decode(poll.result.signature) }}); + resolved = true; + break; + }} + if (poll.status === "rejected") throw new Error(poll.result?.error || "User rejected the request"); + if (poll.status === "expired") throw new Error("FnzeroSafe signing request expired"); + await sleep(500); + }} + if (!resolved) throw new Error("FnzeroSafe signing request timed out"); + }} + return signed; + }}, + }}, + "solana:signMessage": {{ + version: "1.0.0", + signMessage: async (...inputs) => {{ + const signed = []; + for (const input of inputs) {{ + const message = input?.message || input; + const result = await requestMessageSignature(message); + if (!result.signature) throw new Error("FnzeroSafe did not return a message signature"); + const messageBytes = message instanceof Uint8Array + ? message + : message instanceof ArrayBuffer + ? new Uint8Array(message) + : ArrayBuffer.isView(message) + ? new Uint8Array(message.buffer, message.byteOffset, message.byteLength) + : new TextEncoder().encode(String(message || "")); + signed.push({{ signedMessage: messageBytes, signature: base58Decode(result.signature) }}); + }} + return signed; + }}, + }}, + }}, + }}; + function isFnzeroSafeEntry(entry) {{ + const maybeWallet = entry?.wallet || entry?.adapter?.wallet || entry?.adapter || entry; + return ( + maybeWallet === standardWallet || + maybeWallet?.name === walletName || + maybeWallet?.label === walletName || + entry?.__fnzeroWallet === true + ); + }} + function moveFnzeroFirst(list) {{ + try {{ + if (!Array.isArray(list)) return; + const index = list.findIndex(isFnzeroSafeEntry); + if (index > 0) list.unshift(list.splice(index, 1)[0]); + }} catch (_) {{}} + }} + const fnzeroWalletRegistrar = Object.assign(({{ register }}) => register(standardWallet), {{ __fnzeroWallet: true }}); + function prioritizeFnzeroSafes(api) {{ + try {{ + const wallets = window.navigator.wallets || (window.navigator.wallets = []); + const existing = wallets.findIndex(isFnzeroSafeEntry); + if (existing >= 0) wallets.splice(existing, 1); + wallets.unshift(fnzeroWalletRegistrar); + }} catch (_) {{}} + try {{ + if (api && typeof api.get === "function") moveFnzeroFirst(api.get()); + }} catch (_) {{}} + }} + let fnzeroExpandAttemptedAt = 0; + let fnzeroDomPrioritizePending = false; + function visibleElement(element) {{ + try {{ + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }} catch (_) {{ + return false; + }} + }} + function walletText(element) {{ + return String(element?.textContent || "").replace(/\s+/g, " ").trim(); + }} + function textLooksLikeWalletOption(text) {{ + return /FnzeroSafe|Solflare|Phantom|Backpack|OKX|Binance|Magic Eden|SquadsX|Coinbase|Glow|Slope|Torus|Ledger|Wallet/i.test(text); + }} + function elementLooksLikeWalletItem(element) {{ + if (!element || element.nodeType !== 1 || !visibleElement(element)) return false; + const text = walletText(element); + if (!textLooksLikeWalletOption(text)) return false; + const rect = element.getBoundingClientRect(); + return rect.width >= 180 && rect.height >= 36 && rect.height <= 180; + }} + function walletOptionItem(element) {{ + if (!element || element.nodeType !== 1 || !walletText(element).includes(walletName)) return null; + let current = element.closest("button, [role='button'], a, li, [data-testid], [class*='wallet'], [class*='Wallet']") || element; + for (let depth = 0; current && current !== document.body && depth < 8; depth += 1, current = current.parentElement) {{ + const parent = current.parentElement; + if (!parent || !elementLooksLikeWalletItem(current)) continue; + const walletItems = Array.from(parent.children).filter(elementLooksLikeWalletItem); + if (walletItems.length >= 2 && walletItems.includes(current)) return current; + }} + return null; + }} + function walletListItemsFor(item) {{ + const parent = item?.parentElement; + if (!parent) return []; + return Array.from(parent.children).filter(elementLooksLikeWalletItem); + }} + function maybeExpandAllWallets() {{ + const now = Date.now(); + if (now - fnzeroExpandAttemptedAt < 4000) return; + const controls = Array.from(document.querySelectorAll("button, [role='button'], a")); + const allWallets = controls.find((element) => /^All Wallets$/i.test(walletText(element)) && visibleElement(element)); + if (!allWallets) return; + fnzeroExpandAttemptedAt = now; + allWallets.click(); + }} + function prioritizeFnzeroSafeDom() {{ + try {{ + const matches = Array.from(document.querySelectorAll("button, [role='button'], a, li, [data-testid], [class*='wallet'], [class*='Wallet']")) + .map(walletOptionItem) + .filter(Boolean); + if (matches.length === 0) {{ + maybeExpandAllWallets(); + return; + }} + for (const option of matches) {{ + const walletItems = walletListItemsFor(option); + const firstWalletItem = walletItems[0]; + if (!firstWalletItem || firstWalletItem === option) continue; + option.parentElement.insertBefore(option, firstWalletItem); + }} + }} catch (_) {{}} + }} + function schedulePrioritizeFnzeroSafeDom() {{ + if (fnzeroDomPrioritizePending) return; + fnzeroDomPrioritizePending = true; + window.requestAnimationFrame(() => {{ + fnzeroDomPrioritizePending = false; + prioritizeFnzeroSafeDom(); + }}); + }} + function installFnzeroSafeDomPrioritizer() {{ + try {{ + prioritizeFnzeroSafeDom(); + const observer = new MutationObserver(() => {{ + schedulePrioritizeFnzeroSafeDom(); + }}); + observer.observe(document.documentElement, {{ childList: true, subtree: true }}); + }} catch (_) {{}} + }} + function registerStandardWallet(wallet) {{ + const callback = (api) => {{ + if (!api || typeof api.register !== "function") return; + api.register(wallet); + prioritizeFnzeroSafes(api); + window.setTimeout(announceConnected, 0); + window.setTimeout(announceConnected, 250); + }}; + try {{ + const event = new Event("wallet-standard:register-wallet", {{ + bubbles: false, + cancelable: false, + composed: false, + }}); + Object.defineProperty(event, "detail", {{ value: callback }}); + window.dispatchEvent(event); + }} catch (_) {{}} + try {{ + window.addEventListener("wallet-standard:app-ready", (event) => callback(event.detail)); + }} catch (_) {{}} + try {{ + const wallets = window.navigator.wallets || (window.navigator.wallets = []); + const existing = wallets.findIndex(isFnzeroSafeEntry); + if (existing >= 0) wallets.splice(existing, 1); + wallets.unshift(fnzeroWalletRegistrar); + }} catch (_) {{}} + }} + setAutoConnectHints(); + Object.defineProperty(window, "solana", {{ value: provider, configurable: true }}); + window.phantom = window.phantom || {{}}; + Object.defineProperty(window.phantom, "solana", {{ value: provider, configurable: true }}); + Object.defineProperty(window, "solflare", {{ value: provider, configurable: true }}); + Object.defineProperty(window, "fnzeroWallet", {{ value: provider, configurable: true }}); + registerStandardWallet(standardWallet); + installFnzeroSafeDomPrioritizer(); + [0, 250, 750, 1500, 3000].forEach((delay) => window.setTimeout(prioritizeFnzeroSafes, delay)); + [0, 250, 750, 1500, 3000].forEach((delay) => window.setTimeout(prioritizeFnzeroSafeDom, delay)); + [0, 250, 750, 1500, 3000].forEach((delay) => window.setTimeout(announceConnected, delay)); +}})(); +"# + )) +} + +/// Open a URL in the system default browser (not the Tauri webview). +#[tauri::command] +fn open_external_url(url: String) -> Result<(), String> { + let url = url.trim().to_string(); + if !is_allowed_external_https_url(&url) { + return Err("only https URLs can be opened externally".to_string()); + } + spawn_system_browser(&url) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +fn dapp_open_tab( + app: tauri::AppHandle, + state: tauri::State<'_, DappBridgeState>, + tab_id: String, + url: String, + app_id: Option, + wallet_public_key: Option, + network: String, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + let label = dapp_tab_label(&tab_id)?; + let url = parse_dapp_browser_url(&url)?; + let dapp = app_id.as_deref().and_then(allowed_dapp); + if let Some(dapp) = dapp.as_ref() { + if !is_allowed_dapp_url(dapp, &url) { + return Err("dapp tab URL does not match the selected DApp".to_string()); + } + } + + let wallet_public_key = match (dapp.as_ref(), wallet_public_key) { + (Some(_), Some(value)) => { + let trimmed = value.trim().to_string(); + if !is_likely_solana_pubkey(&trimmed) { + return Err("invalid wallet public key".to_string()); + } + Some(trimmed) + } + (Some(_), None) => { + return Err("wallet public key is required for connected DApp tabs".to_string()) + } + (None, _) => None, + }; + let network = network.trim().to_string(); + if network.is_empty() || network.len() > 256 || network.chars().any(|ch| ch.is_control()) { + return Err("invalid network".to_string()); + } + + if let Some(existing) = app.get_webview(&label) { + let _ = existing.close(); + } + state + .sessions + .lock() + .map_err(|_| "dapp session lock poisoned".to_string())? + .remove(&label); + + let main_window = app + .get_window("main") + .ok_or_else(|| "main window is unavailable".to_string())?; + let bounds = dapp_webview_bounds(x, y, width, height)?; + let tab_id_for_nav = tab_id.trim().to_string(); + let tab_id_for_title = tab_id_for_nav.clone(); + let tab_id_for_new_window = tab_id_for_nav.clone(); + let dapp_for_nav = dapp.clone(); + let app_for_nav = app.clone(); + let app_for_title = app.clone(); + let app_for_new_window = app.clone(); + let data_directory = dapp_browser_data_directory(&app)?; + let mut builder = WebviewBuilder::new(label.clone(), WebviewUrl::External(url.clone())) + .data_directory(data_directory) + .on_navigation(move |target_url| { + let allowed = match dapp_for_nav.as_ref() { + Some(dapp) => is_allowed_connected_dapp_navigation_url(dapp, target_url), + None => is_safe_dapp_webview_navigation_url(target_url), + }; + if is_safe_browser_url(target_url) { + let _ = app_for_nav.emit_to( + "main", + DAPP_TAB_URL_EVENT, + DappTabUrlEvent { + tab_id: tab_id_for_nav.clone(), + url: target_url.as_str().to_string(), + loaded: false, + }, + ); + } + allowed + }) + .on_page_load({ + let app = app.clone(); + let tab_id = tab_id.trim().to_string(); + move |_webview, payload| { + if !is_safe_browser_url(payload.url()) { + return; + } + let _ = app.emit_to( + "main", + DAPP_TAB_URL_EVENT, + DappTabUrlEvent { + tab_id: tab_id.clone(), + url: payload.url().to_string(), + loaded: true, + }, + ); + } + }) + .on_document_title_changed(move |_webview, title| { + let title = title.trim().chars().take(120).collect::(); + if !title.is_empty() { + let _ = app_for_title.emit_to( + "main", + DAPP_TAB_TITLE_EVENT, + DappTabTitleEvent { + tab_id: tab_id_for_title.clone(), + title, + }, + ); + } + }) + .on_new_window(move |target_url, _features| { + if is_safe_browser_url(&target_url) { + let _ = app_for_new_window.emit_to( + "main", + DAPP_NEW_WINDOW_EVENT, + DappNewWindowEvent { + source_tab_id: tab_id_for_new_window.clone(), + url: target_url.as_str().to_string(), + }, + ); + } + tauri::webview::NewWindowResponse::Deny + }); + + if let (Some(dapp), Some(wallet_public_key)) = (dapp.as_ref(), wallet_public_key.as_ref()) { + let init_script = dapp_provider_script(dapp, wallet_public_key, &network)?; + builder = builder.initialization_script(&init_script); + } + + let webview = main_window + .add_child(builder, bounds.position, bounds.size) + .map_err(|error| format!("failed to open dapp tab: {error}"))?; + webview + .set_bounds(bounds) + .map_err(|error| format!("failed to position dapp tab: {error}"))?; + webview + .hide() + .map_err(|error| format!("failed to hide loading dapp tab: {error}"))?; + + if let (Some(dapp), Some(wallet_public_key)) = (dapp, wallet_public_key) { + state + .sessions + .lock() + .map_err(|_| "dapp session lock poisoned".to_string())? + .insert( + label, + DappSession { + app_id: dapp.id.to_string(), + app_name: dapp.name.to_string(), + url: url.as_str().to_string(), + wallet_public_key, + network, + opened_at_ms: now_ms(), + }, + ); + } + Ok(()) +} + +#[tauri::command] +fn dapp_navigate_tab( + app: tauri::AppHandle, + state: tauri::State<'_, DappBridgeState>, + tab_id: String, + url: String, +) -> Result<(), String> { + let label = dapp_tab_label(&tab_id)?; + let url = parse_dapp_browser_url(&url)?; + let dapp_session = state + .sessions + .lock() + .map_err(|_| "dapp session lock poisoned".to_string())? + .get(&label) + .cloned(); + if let Some(session) = dapp_session.as_ref() { + let dapp = allowed_dapp(&session.app_id).ok_or_else(|| "unsupported dapp".to_string())?; + if !is_allowed_dapp_url(&dapp, &url) { + return Err( + "connected DApp tabs can only navigate inside their DApp domain".to_string(), + ); + } + } + let webview = app + .get_webview(&label) + .ok_or_else(|| "dapp tab is not open".to_string())?; + webview + .navigate(url) + .map_err(|error| format!("failed to navigate dapp tab: {error}"))?; + Ok(()) +} + +#[tauri::command] +fn dapp_set_active_tab( + app: tauri::AppHandle, + tab_id: Option, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + let active_label = tab_id.as_deref().map(dapp_tab_label).transpose()?; + let bounds = if active_label.is_some() { + Some(dapp_webview_bounds(x, y, width, height)?) + } else { + None + }; + for (label, webview) in app.webviews() { + if !label.starts_with(DAPP_TAB_LABEL_PREFIX) { + continue; + } + if active_label.as_deref() == Some(label.as_str()) { + if let Some(bounds) = bounds { + webview + .set_bounds(bounds) + .map_err(|error| format!("failed to position dapp tab: {error}"))?; + } + webview + .show() + .map_err(|error| format!("failed to show dapp tab: {error}"))?; + } else { + let _ = webview.hide(); + } + } + Ok(()) +} + +#[tauri::command] +fn dapp_close_tab( + app: tauri::AppHandle, + state: tauri::State<'_, DappBridgeState>, + tab_id: String, +) -> Result<(), String> { + let label = dapp_tab_label(&tab_id)?; + if let Some(webview) = app.get_webview(&label) { + webview + .close() + .map_err(|error| format!("failed to close dapp tab: {error}"))?; + } + state + .sessions + .lock() + .map_err(|_| "dapp session lock poisoned".to_string())? + .remove(&label); + Ok(()) +} + +#[tauri::command] +fn dapp_submit_sign_request( + webview: tauri::Webview, + app: tauri::AppHandle, + state: tauri::State<'_, DappBridgeState>, + method: String, + transaction_base64: Option, + transaction_format: Option, + message_base64: Option, +) -> Result { + let webview_label = webview.label().to_string(); + let Some(_tab_id) = dapp_tab_id_from_label(&webview_label) else { + return Err("dapp signing requests are only accepted from dapp tabs".to_string()); + }; + let method = validate_dapp_method(&method)?; + let (transaction_base64, transaction_format, message_base64) = if method == "signMessage" { + let message_base64 = validate_dapp_message_base64( + message_base64 + .as_deref() + .ok_or_else(|| "message payload is required".to_string())?, + )?; + ("".to_string(), "message".to_string(), Some(message_base64)) + } else { + let transaction_base64 = validate_dapp_transaction_base64( + transaction_base64 + .as_deref() + .ok_or_else(|| "transaction payload is required".to_string())?, + )?; + let transaction_format = + validate_transaction_format(transaction_format.as_deref().unwrap_or("auto"))?; + (transaction_base64, transaction_format, None) + }; + let session = state + .sessions + .lock() + .map_err(|_| "dapp session lock poisoned".to_string())? + .get(&webview_label) + .cloned() + .ok_or_else(|| "no active dapp session for this tab".to_string())?; + if now_ms().saturating_sub(session.opened_at_ms) > 12 * 60 * 60 * 1000 { + return Err("dapp session expired".to_string()); + } + + let request_id = dapp_request_id(); + let event = DappSignRequestEvent { + request_id: request_id.clone(), + app_id: session.app_id, + app_name: session.app_name, + app_url: session.url, + method, + wallet_public_key: session.wallet_public_key, + network: session.network, + transaction_base64, + transaction_format, + message_base64, + created_at_ms: now_ms(), + }; + + let mut requests = state + .requests + .lock() + .map_err(|_| "dapp request lock poisoned".to_string())?; + requests.retain(|_, pending| { + now_ms().saturating_sub(pending.event.created_at_ms) <= DAPP_REQUEST_TTL_MS + && pending.result.is_none() + }); + requests.insert( + request_id.clone(), + DappPendingRequest { + webview_label, + event: event.clone(), + result: None, + }, + ); + drop(requests); + + app.emit_to("main", DAPP_SIGN_REQUEST_EVENT, event) + .map_err(|error| format!("failed to notify main window: {error}"))?; + Ok(request_id) +} + +#[tauri::command] +fn dapp_poll_sign_request( + webview: tauri::Webview, + state: tauri::State<'_, DappBridgeState>, + request_id: String, +) -> Result { + let webview_label = webview.label().to_string(); + if dapp_tab_id_from_label(&webview_label).is_none() { + return Err("dapp signing requests are only polled from dapp tabs".to_string()); + } + let request_id = request_id.trim(); + let mut requests = state + .requests + .lock() + .map_err(|_| "dapp request lock poisoned".to_string())?; + let Some(pending) = requests.get(request_id) else { + return Ok(DappPollResponse { + status: "expired", + result: None, + }); + }; + if pending.webview_label != webview_label { + return Err("dapp signing request does not belong to this tab".to_string()); + } + if now_ms().saturating_sub(pending.event.created_at_ms) > DAPP_REQUEST_TTL_MS { + requests.remove(request_id); + return Ok(DappPollResponse { + status: "expired", + result: None, + }); + } + if let Some(result) = pending.result.clone() { + requests.remove(request_id); + return Ok(DappPollResponse { + status: if result.approved { + "approved" + } else { + "rejected" + }, + result: Some(result), + }); + } + Ok(DappPollResponse { + status: "pending", + result: None, + }) +} + +#[tauri::command] +fn resolve_dapp_sign_request( + state: tauri::State<'_, DappBridgeState>, + request_id: String, + result: DappSignResult, +) -> Result<(), String> { + let request_id = request_id.trim(); + let mut requests = state + .requests + .lock() + .map_err(|_| "dapp request lock poisoned".to_string())?; + let pending = requests + .get_mut(request_id) + .ok_or_else(|| "dapp signing request is no longer pending".to_string())?; + if now_ms().saturating_sub(pending.event.created_at_ms) > DAPP_REQUEST_TTL_MS { + requests.remove(request_id); + return Err("dapp signing request expired".to_string()); + } + pending.result = Some(result); + Ok(()) +} + +#[tauri::command] +fn pick_source_directory() -> Result, String> { + Ok(rfd::FileDialog::new() + .set_title("Select Solana Program Source Directory") + .pick_folder() + .map(|path| path.to_string_lossy().to_string())) +} + +fn safe_download_filename(filename: &str) -> Result { + let trimmed = filename.trim(); + if trimmed.is_empty() + || trimmed.len() > 160 + || trimmed == "." + || trimmed == ".." + || trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.chars().any(|ch| ch.is_control()) + { + return Err("invalid download filename".to_string()); + } + if !trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_')) + { + return Err( + "download filename may only contain letters, numbers, dots, dashes, and underscores" + .to_string(), + ); + } + Ok(trimmed.to_string()) +} + +fn downloads_dir() -> Result { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| "HOME directory is unavailable".to_string())?; + Ok(home.join("Downloads")) +} + +fn non_overwriting_path(directory: &Path, filename: &str) -> PathBuf { + let candidate = directory.join(filename); + if !candidate.exists() { + return candidate; + } + let path = Path::new(filename); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(filename); + let extension = path.extension().and_then(|value| value.to_str()); + for index in 1..10_000 { + let next_name = match extension { + Some(extension) if !extension.is_empty() => format!("{stem}-{index}.{extension}"), + _ => format!("{stem}-{index}"), + }; + let next_path = directory.join(next_name); + if !next_path.exists() { + return next_path; + } + } + directory.join(format!("{stem}-{}", uuid_like_timestamp())) +} + +fn uuid_like_timestamp() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().to_string()) + .unwrap_or_else(|_| "now".to_string()) +} + +#[tauri::command] +fn save_download_file(filename: String, content: String) -> Result { + if content.len() > MAX_DOWNLOAD_FILE_BYTES { + return Err("download file is too large".to_string()); + } + let filename = safe_download_filename(&filename)?; + let directory = downloads_dir()?; + std::fs::create_dir_all(&directory) + .map_err(|error| format!("failed to create Downloads directory: {error}"))?; + let path = non_overwriting_path(&directory, &filename); + std::fs::write(&path, content.as_bytes()) + .map_err(|error| format!("failed to write download file: {error}"))?; + Ok(path.to_string_lossy().to_string()) +} + +#[tauri::command] +fn open_download_file_location(path: String) -> Result<(), String> { + let raw_path = PathBuf::from(path.trim()); + if !raw_path.is_absolute() { + return Err("download path must be absolute".to_string()); + } + let downloads = downloads_dir()?; + let canonical_downloads = downloads + .canonicalize() + .map_err(|error| format!("failed to read Downloads directory: {error}"))?; + let canonical_path = raw_path + .canonicalize() + .map_err(|error| format!("download file is unavailable: {error}"))?; + if !canonical_path.starts_with(&canonical_downloads) { + return Err("can only open files saved under Downloads".to_string()); + } + reveal_file_in_system_file_manager(&canonical_path) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .manage(DappBridgeState::default()) + .invoke_handler(tauri::generate_handler![ + proxy_api_request, + open_external_url, + dapp_open_tab, + dapp_navigate_tab, + dapp_set_active_tab, + dapp_close_tab, + dapp_submit_sign_request, + dapp_poll_sign_request, + resolve_dapp_sign_request, + biometric_wallet_status, + biometric_wallet_store_password, + biometric_wallet_get_password, + biometric_wallet_delete_password, + pick_source_directory, + save_download_file, + open_download_file_location + ]) + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +#[cfg(test)] +mod tests { + use super::*; + use aws_lc_rs::{ + encoding::{AsDer, PublicKeyX509Der}, + rsa::{KeySize, OaepPrivateDecryptingKey, PrivateDecryptingKey}, + }; + + #[test] + fn secure_body_envelope_round_trips_with_backend_key_format() { + let private_key = PrivateDecryptingKey::generate(KeySize::Rsa2048).unwrap(); + let public_key = private_key.public_key(); + let public_key_der = AsDer::>::as_der(&public_key).unwrap(); + let public_key_pem = pem_rfc7468::encode_string( + "PUBLIC KEY", + pem_rfc7468::LineEnding::LF, + public_key_der.as_ref(), + ) + .unwrap(); + assert!(public_key_pem.starts_with("-----BEGIN PUBLIC KEY-----\n")); + assert!(public_key_pem.ends_with("-----END PUBLIC KEY-----\n")); + + let body = r#"{"password":"not-a-real-password","value":42}"#; + let envelope: serde_json::Value = + serde_json::from_str(&encrypt_secure_body(body, &public_key_pem).unwrap()).unwrap(); + assert_eq!(envelope["version"].as_u64(), Some(1)); + + let encrypted_key = BASE64 + .decode(envelope["encrypted_key"].as_str().unwrap()) + .unwrap(); + let iv = BASE64.decode(envelope["iv"].as_str().unwrap()).unwrap(); + let ciphertext = BASE64 + .decode(envelope["ciphertext"].as_str().unwrap()) + .unwrap(); + + let private_key = OaepPrivateDecryptingKey::new(private_key).unwrap(); + let mut decrypted_key = Zeroizing::new(vec![0_u8; private_key.min_output_size()]); + let aes_key_len = private_key + .decrypt( + &OAEP_SHA256_MGF1SHA256, + &encrypted_key, + decrypted_key.as_mut_slice(), + None, + ) + .unwrap() + .len(); + assert_eq!(aes_key_len, 32); + + let cipher = Aes256Gcm::new_from_slice(&decrypted_key[..aes_key_len]).unwrap(); + let plaintext = Zeroizing::new( + cipher + .decrypt(Nonce::from_slice(&iv), ciphertext.as_ref()) + .unwrap(), + ); + assert_eq!(plaintext.as_slice(), body.as_bytes()); + } + + #[test] + fn secure_body_rejects_non_spki_pem_label() { + let invalid_pem = "-----BEGIN RSA PUBLIC KEY-----\nAA==\n-----END RSA PUBLIC KEY-----\n"; + let error = encrypt_secure_body("{}", invalid_pem).unwrap_err(); + assert_eq!(error, "invalid secure API public key PEM label"); + } + + #[test] + fn external_url_guard_accepts_only_structural_https_urls() { + assert!(is_allowed_external_https_url( + "https://solscan.io/tx/abc?cluster=devnet" + )); + assert!(!is_allowed_external_https_url("http://solscan.io/tx/abc")); + assert!(!is_allowed_external_https_url("https://")); + assert!(!is_allowed_external_https_url("https://@example.com")); + assert!(!is_allowed_external_https_url( + "https://user:pass@example.com" + )); + assert!(!is_allowed_external_https_url("https://.example.com")); + assert!(!is_allowed_external_https_url("https://example.com.")); + assert!(!is_allowed_external_https_url("https://example.com\n.evil")); + } + + #[test] + fn connected_dapp_navigation_stays_on_selected_domain() { + let pumpfun = allowed_dapp("pumpfun").unwrap(); + let same_domain = "https://pump.fun/coin/example".parse().unwrap(); + let subdomain = "https://frontend-api.pump.fun/".parse().unwrap(); + let other_https = "https://example.com/".parse().unwrap(); + + assert!(is_allowed_connected_dapp_navigation_url( + &pumpfun, + &same_domain + )); + assert!(is_allowed_connected_dapp_navigation_url( + &pumpfun, &subdomain + )); + assert!(!is_allowed_connected_dapp_navigation_url( + &pumpfun, + &other_https + )); + } + + #[test] + fn connected_dapp_navigation_rejects_data_pages() { + let pumpfun = allowed_dapp("pumpfun").unwrap(); + let about_blank = "about:blank".parse().unwrap(); + let blob_page = "blob:https://pump.fun/example".parse().unwrap(); + let data_page = "data:text/html;base64,PHNjcmlwdD48L3NjcmlwdD4=" + .parse() + .unwrap(); + + assert!(is_allowed_connected_dapp_navigation_url( + &pumpfun, + &about_blank + )); + assert!(is_allowed_connected_dapp_navigation_url( + &pumpfun, &blob_page + )); + assert!(!is_allowed_connected_dapp_navigation_url( + &pumpfun, &data_page + )); + } +} diff --git a/ui/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs similarity index 100% rename from ui/src-tauri/src/main.rs rename to apps/desktop/src-tauri/src/main.rs diff --git a/ui/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json similarity index 64% rename from ui/src-tauri/tauri.conf.json rename to apps/desktop/src-tauri/tauri.conf.json index 25f26d2..1a3270b 100644 --- a/ui/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Sol SafeKey", + "productName": "FnzeroSafe", "version": "0.4.0", "identifier": "dev.sol-safekey.ui", "build": { @@ -13,7 +13,7 @@ "withGlobalTauri": true, "windows": [ { - "title": "Sol SafeKey", + "title": "FnzeroSafe", "width": 1100, "height": 760, "resizable": true, @@ -22,7 +22,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self' tauri: asset: http://127.0.0.1:3840 http://127.0.0.1:3841; connect-src 'self' http://127.0.0.1:3841 https: wss:; img-src 'self' asset: data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'" } }, "bundle": { diff --git a/ui/src-tauri/tauri.desktop-dev.conf.json b/apps/desktop/src-tauri/tauri.desktop-dev.conf.json similarity index 100% rename from ui/src-tauri/tauri.desktop-dev.conf.json rename to apps/desktop/src-tauri/tauri.desktop-dev.conf.json diff --git a/ui/src/app/[locale]/layout.tsx b/apps/desktop/src/app/[locale]/layout.tsx similarity index 100% rename from ui/src/app/[locale]/layout.tsx rename to apps/desktop/src/app/[locale]/layout.tsx diff --git a/ui/src/app/[locale]/page.tsx b/apps/desktop/src/app/[locale]/page.tsx similarity index 86% rename from ui/src/app/[locale]/page.tsx rename to apps/desktop/src/app/[locale]/page.tsx index b5f980e..072ba7f 100644 --- a/ui/src/app/[locale]/page.tsx +++ b/apps/desktop/src/app/[locale]/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import QRCode from "qrcode"; import { toast } from "sonner"; import { useTranslations } from '@/hooks/useTranslations'; import { @@ -33,10 +34,24 @@ import { Trash2, ExternalLink, Menu, + PanelLeftClose, + PanelLeftOpen, + CircleDot, Compass, FolderOpen, CheckCircle2, XCircle, + Sun, + Moon, + Search, + Flame, + Star, + BarChart3, + Fingerprint, + EyeOff, + HelpCircle, + Minus, + QrCode, } from "lucide-react"; import LanguageSwitcher from '@/components/LanguageSwitcher'; import { FieldHelp } from "@/components/FieldHelp"; @@ -66,6 +81,7 @@ import { defaultRpcProfileId, emptyWorkspace, loadDownloadHistory, + loadAppUiTheme, loadInitialRpcState, loadStoredWalletId, loadWorkspace, @@ -76,10 +92,12 @@ import { saveCurrentWalletId, saveCustomRpcProfiles, saveDownloadHistory, + saveAppUiTheme, saveSelectedRpcProfileId, saveWorkspace, validateRpcUrl, type AppNetwork, + type AppUiTheme, type DownloadHistoryItem, type ProgramDeploymentHistoryItem, type ProgramDeploymentPlan, @@ -156,6 +174,127 @@ function isTauriWebview(): boolean { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; } +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error.trim()) return error; + return fallback; +} + +function decodeBase64Bytes(value?: string): Uint8Array | null { + if (!value || typeof window === "undefined") return null; + try { + const binary = window.atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; + } catch { + return null; + } +} + +function dappMessagePreview(value?: string): { text: string; byteLength: number } | null { + const bytes = decodeBase64Bytes(value); + if (!bytes) return null; + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return { text: text.replace(/\u0000/g, "\\0"), byteLength: bytes.length }; + } catch { + return { text: value || "", byteLength: bytes.length }; + } +} + +type ThemeOption = { id: AppUiTheme; label: string; icon: ReactNode }; + +function ThemeSwitcher({ + appTheme, + compact = false, + label, + options, + onSelect, +}: { + appTheme: AppUiTheme; + compact?: boolean; + label: string; + options: ThemeOption[]; + onSelect: (theme: AppUiTheme) => void; +}) { + const [open, setOpen] = useState(false); + const switcherRef = useRef(null); + const currentThemeOption = options.find((option) => option.id === appTheme) || options[0]; + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: PointerEvent) => { + if (!switcherRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + return ( +

+ {!compact &&

{label}

} + + {open && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} + const WALLET_PASSWORD_FORM_IDS = new Set([ "decrypt", "unlock", @@ -198,6 +337,7 @@ const CREATE_PASSWORD_FORM_IDS = new Set([ "create-encrypted", "create-keystore", "import-keystore", + "import-mnemonic", ]); const MASTER_PASSWORD_FORM_IDS = new Set([ @@ -743,23 +883,148 @@ function dedupeProgramDeploymentHistory( type WorkspaceProposalAction = "approve" | "reject" | "execute"; type PasswordPromptField = "password" | "master_password"; +type ExportBundleItem = "keystore" | "private-key" | "mnemonic"; + +type ExportBundleSelection = Record; type PasswordPromptRequest = | { kind: "form"; formId: string; formState: FormState; fields?: PasswordPromptField[] } | { kind: "create-password"; formId: string; formState: FormState } | { kind: "master-password"; formId: string; formState: FormState } | { kind: "proposal"; proposal: WorkspaceProposal; action: WorkspaceProposalAction; formState: FormState } + | { kind: "enable-biometric"; wallet: SavedWallet; formState: FormState } + | { kind: "export-bundle"; wallet: SavedWallet; formState: FormState } | { kind: "export-keystore"; wallet: SavedWallet; formState: FormState } | { kind: "export-private-key"; wallet: SavedWallet; formState: FormState } + | { kind: "export-mnemonic"; wallet: SavedWallet; formState: FormState } | { kind: "migrate-keystore"; wallet: SavedWallet; formState: FormState }; -type DappId = "jupiter" | "pumpfun" | "raydium" | "meteora"; +interface ExportedPrivateKeyPreview { + walletName: string; + publicKey: string; + privateKey: string; +} + +interface ExportedMnemonicPreview { + walletName: string; + publicKey: string; + mnemonic: string; +} + +type SensitiveExportKind = "private-key" | "mnemonic"; + +interface SensitiveExportPreview { + kind: SensitiveExportKind; + walletName: string; + publicKey: string; + value: string; + title: string; + hint: string; + warning: string; + qrCanvasLabel: string; + plaintextLabel: string; + temporaryHint: string; + hiddenLabel: string; + segmentsTitle: string; + segmentsHelp: string; + segmentsHint: string; + copyLabel: string; + downloadLabel: string; + downloadSuffix: string; + copyId: string; + segmentCopyPrefix: string; + qrFailedMessage: string; +} + +type PrivateKeyExportMode = "simple" | "advanced"; + +const MIN_PRIVATE_KEY_SEGMENTS = 1; +const MAX_PRIVATE_KEY_SEGMENTS = 12; +const DEFAULT_PRIVATE_KEY_SEGMENTS = 1; + +function clampPrivateKeySegmentCount(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_PRIVATE_KEY_SEGMENTS; + return Math.min(MAX_PRIVATE_KEY_SEGMENTS, Math.max(MIN_PRIVATE_KEY_SEGMENTS, Math.round(value))); +} + +function splitPrivateKeyIntoSegments(privateKey: string, count: number): string[] { + const safeCount = clampPrivateKeySegmentCount(count); + const baseSize = Math.floor(privateKey.length / safeCount); + const remainder = privateKey.length % safeCount; + let offset = 0; + return Array.from({ length: safeCount }, (_, index) => { + const size = baseSize + (index < remainder ? 1 : 0); + const segment = privateKey.slice(offset, offset + size); + offset += size; + return segment; + }); +} + +function splitSensitiveExportIntoSegments(value: string, count: number): string[] { + const words = value.trim().split(/\s+/).filter(Boolean); + if (words.length > 1) { + const safeCount = clampPrivateKeySegmentCount(count); + const baseSize = Math.floor(words.length / safeCount); + const remainder = words.length % safeCount; + let offset = 0; + return Array.from({ length: safeCount }, (_, index) => { + const size = baseSize + (index < remainder ? 1 : 0); + const segment = words.slice(offset, offset + size).join(" "); + offset += size; + return segment; + }); + } + return splitPrivateKeyIntoSegments(value, count); +} + +interface BiometricWalletStatus { + supported: boolean; + configured: boolean; + reason?: string | null; +} + +type DappId = + | "jupiter" + | "pumpfun" + | "raydium" + | "meteora" + | "orca" + | "drift" + | "kamino" + | "tensor" + | "magiceden" + | "sanctum"; +type DappChain = "Solana" | "Ethereum" | "BSC" | "Polygon" | "Sui"; +type DappCategoryId = "trend" | "defi" | "trading" | "nft" | "staking"; interface DappCatalogItem { id: DappId; name: string; url: string; + domains: string[]; + chain: DappChain; + category: Exclude; + description: string; + tags: string[]; + rank: number; + metricLabel: string; + metricValue: string; accentClass: string; + iconClass: string; + logoUrl: string; +} + +interface DappBrowserTab { + id: string; + title: string; + url: string; + addressInput: string; + closable: boolean; + showAddressBar: boolean; + appId?: DappId; + walletConnected: boolean; + webviewOpen: boolean; + loading: boolean; } interface DappSignRequestEvent { @@ -767,11 +1032,12 @@ interface DappSignRequestEvent { app_id: string; app_name: string; app_url: string; - method: "signTransaction" | "signAllTransactions" | "signAndSendTransaction" | "sendTransaction" | string; + method: "signTransaction" | "signAllTransactions" | "signAndSendTransaction" | "sendTransaction" | "signMessage" | string; wallet_public_key: string; network: string; transaction_base64: string; transaction_format: "legacy" | "versioned" | "v0" | "auto" | string; + message_base64?: string; created_at_ms: number; } @@ -783,12 +1049,287 @@ interface DappSignResult { recent_blockhash?: string; } +interface DappTransactionInstructionPreview { + index: number; + program_id: string; + account_count: number; + data_bytes: number; +} + +interface DappTransactionPreview { + status: string; + transaction_format: string; + recent_blockhash: string; + required_signatures: number; + required_signer_present: boolean; + signer_accounts: string[]; + writable_accounts: string[]; + readonly_accounts: string[]; + programs: string[]; + instructions: DappTransactionInstructionPreview[]; + warnings: string[]; +} + +const DAPP_CATEGORIES: DappCategoryId[] = ["trend", "defi", "trading", "nft", "staking"]; const DAPP_CATALOG: DappCatalogItem[] = [ - { id: "jupiter", name: "Jupiter", url: "https://jup.ag/swap", accentClass: "from-teal-400/25 to-amber-300/20" }, - { id: "pumpfun", name: "pump.fun", url: "https://pump.fun/", accentClass: "from-lime-300/25 to-pink-400/20" }, - { id: "raydium", name: "Raydium", url: "https://raydium.io/swap/", accentClass: "from-cyan-400/25 to-fuchsia-400/20" }, - { id: "meteora", name: "Meteora", url: "https://app.meteora.ag/", accentClass: "from-orange-300/25 to-sky-400/20" }, + { + id: "jupiter", + name: "Jupiter", + url: "https://jup.ag/swap", + domains: ["jup.ag"], + chain: "Solana", + category: "trading", + description: "Swap aggregator, limit orders, DCA, and liquidity routing for Solana.", + tags: ["Swap", "Aggregator", "Perps"], + rank: 1, + metricLabel: "Category", + metricValue: "DEX", + accentClass: "from-teal-300/35 via-sky-400/25 to-amber-300/25", + iconClass: "bg-teal-300 text-zinc-950", + logoUrl: "https://jup.ag/favicon.ico", + }, + { + id: "pumpfun", + name: "pump.fun", + url: "https://pump.fun/", + domains: ["pump.fun"], + chain: "Solana", + category: "trading", + description: "Token launch and discovery venue for Solana meme assets.", + tags: ["Launchpad", "Meme", "Token"], + rank: 2, + metricLabel: "Category", + metricValue: "Launch", + accentClass: "from-lime-300/35 via-emerald-400/20 to-pink-400/25", + iconClass: "bg-lime-300 text-zinc-950", + logoUrl: "https://pump.fun/favicon.ico", + }, + { + id: "raydium", + name: "Raydium", + url: "https://raydium.io/swap/", + domains: ["raydium.io"], + chain: "Solana", + category: "defi", + description: "AMM swaps, concentrated liquidity pools, and DeFi routing.", + tags: ["DEX", "Liquidity", "AMM"], + rank: 3, + metricLabel: "Category", + metricValue: "DeFi", + accentClass: "from-cyan-300/35 via-blue-400/25 to-fuchsia-400/20", + iconClass: "bg-cyan-300 text-zinc-950", + logoUrl: "https://raydium.io/favicon.ico", + }, + { + id: "meteora", + name: "Meteora", + url: "https://app.meteora.ag/", + domains: ["meteora.ag"], + chain: "Solana", + category: "defi", + description: "Dynamic liquidity markets and yield infrastructure on Solana.", + tags: ["Liquidity", "Yield", "DLMM"], + rank: 4, + metricLabel: "Category", + metricValue: "DeFi", + accentClass: "from-orange-300/35 via-rose-300/20 to-sky-400/25", + iconClass: "bg-orange-300 text-zinc-950", + logoUrl: "https://app.meteora.ag/icons/v2.svg", + }, + { + id: "orca", + name: "Orca", + url: "https://www.orca.so/", + domains: ["orca.so"], + chain: "Solana", + category: "defi", + description: "Solana DEX with Whirlpool concentrated liquidity markets.", + tags: ["DEX", "Whirlpools", "Liquidity"], + rank: 5, + metricLabel: "Category", + metricValue: "DEX", + accentClass: "from-sky-300/35 via-indigo-300/20 to-emerald-300/25", + iconClass: "bg-sky-300 text-zinc-950", + logoUrl: "https://www.orca.so/favicon.ico", + }, + { + id: "drift", + name: "Drift", + url: "https://app.drift.trade/", + domains: ["drift.trade"], + chain: "Solana", + category: "trading", + description: "Perpetuals, spot markets, lending, and structured trading.", + tags: ["Perps", "Trading", "Lending"], + rank: 6, + metricLabel: "Category", + metricValue: "Perps", + accentClass: "from-violet-300/35 via-cyan-300/20 to-slate-200/20", + iconClass: "bg-violet-300 text-zinc-950", + logoUrl: "https://app.drift.trade/favicon.ico", + }, + { + id: "kamino", + name: "Kamino", + url: "https://app.kamino.finance/", + domains: ["kamino.finance"], + chain: "Solana", + category: "defi", + description: "Lending markets, multiply vaults, and liquidity strategies.", + tags: ["Lending", "Vaults", "Yield"], + rank: 7, + metricLabel: "Category", + metricValue: "Lend", + accentClass: "from-emerald-300/35 via-lime-300/20 to-cyan-300/25", + iconClass: "bg-emerald-300 text-zinc-950", + logoUrl: "https://app.kamino.finance/favicon.ico", + }, + { + id: "tensor", + name: "Tensor", + url: "https://www.tensor.trade/", + domains: ["tensor.trade"], + chain: "Solana", + category: "nft", + description: "NFT marketplace and pro trading surface for Solana collections.", + tags: ["NFT", "Marketplace", "Trading"], + rank: 8, + metricLabel: "Category", + metricValue: "NFT", + accentClass: "from-fuchsia-300/35 via-violet-300/25 to-cyan-300/20", + iconClass: "bg-fuchsia-300 text-zinc-950", + logoUrl: "https://www.tensor.trade/favicon.ico", + }, + { + id: "magiceden", + name: "Magic Eden", + url: "https://magiceden.io/", + domains: ["magiceden.io"], + chain: "Solana", + category: "nft", + description: "NFT marketplace with Solana collection discovery and trading.", + tags: ["NFT", "Marketplace", "Collectibles"], + rank: 9, + metricLabel: "Category", + metricValue: "NFT", + accentClass: "from-pink-300/35 via-purple-300/20 to-amber-200/25", + iconClass: "bg-pink-300 text-zinc-950", + logoUrl: "https://magiceden.io/img/favicon/android-chrome-192x192.png", + }, + { + id: "sanctum", + name: "Sanctum", + url: "https://app.sanctum.so/", + domains: ["sanctum.so"], + chain: "Solana", + category: "staking", + description: "Liquid staking, LST swaps, and validator-backed yield routes.", + tags: ["LST", "Staking", "Yield"], + rank: 10, + metricLabel: "Category", + metricValue: "Stake", + accentClass: "from-amber-200/35 via-teal-300/20 to-indigo-300/25", + iconClass: "bg-amber-200 text-zinc-950", + logoUrl: "https://app.sanctum.so/favicon.ico", + }, ]; +const SOLANA_DAPP_CATALOG = DAPP_CATALOG.filter((dapp) => dapp.chain === "Solana"); + +function DappLogo({ dapp, size = "md" }: { dapp: DappCatalogItem; size?: "sm" | "md" | "lg" }) { + const [failed, setFailed] = useState(false); + const sizeClass = size === "sm" ? "h-8 w-8" : size === "lg" ? "h-11 w-11" : "h-10 w-10"; + const imageSizeClass = size === "sm" ? "h-6 w-6" : size === "lg" ? "h-8 w-8" : "h-7 w-7"; + if (failed) { + return ( + + {dapp.name.slice(0, 1)} + + ); + } + return ( + + {/* eslint-disable-next-line @next/next/no-img-element -- Remote DApp favicons need per-image fallback handling. */} + {`${dapp.name} setFailed(true)} + /> + + ); +} + +const DAPP_HOME_TAB_ID = "dapp-home"; +const DAPP_HOME_TAB: DappBrowserTab = { + id: DAPP_HOME_TAB_ID, + title: "DApp 首页", + url: "", + addressInput: "", + closable: false, + showAddressBar: false, + walletConnected: false, + webviewOpen: false, + loading: false, +}; + +interface DappTabUrlEvent { + tab_id: string; + url: string; + loaded?: boolean; +} + +interface DappTabTitleEvent { + tab_id: string; + title: string; +} + +interface DappNewWindowEvent { + source_tab_id: string; + url: string; +} + +function newDappTabId(): string { + return `dapp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizeDappBrowserUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const parsed = new URL(withScheme); + if (parsed.protocol === "https:") return parsed.toString(); + if ( + parsed.protocol === "http:" && + (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1") + ) { + return parsed.toString(); + } + throw new Error("只支持 https 网址,或本机 localhost http 地址。"); +} + +function hostMatchesDomain(host: string, domain: string): boolean { + return host === domain || host.endsWith(`.${domain}`); +} + +function dappForUrl(url: string): DappCatalogItem | undefined { + try { + const parsed = new URL(url); + const host = parsed.hostname.toLowerCase(); + if (parsed.protocol !== "https:") return undefined; + return SOLANA_DAPP_CATALOG.find((dapp) => dapp.domains.some((domain) => hostMatchesDomain(host, domain))); + } catch { + return undefined; + } +} + +function dappNativeWindowTopOffset(): number { + if (typeof window === "undefined" || !isTauriWebview()) return 0; + const measured = Math.round(window.outerHeight - window.innerHeight); + if (Number.isFinite(measured) && measured > 0 && measured <= 96) return measured; + const platform = window.navigator.platform.toLowerCase(); + return platform.includes("mac") ? 32 : 0; +} interface WalletTokenAsset { account: string; @@ -1087,6 +1628,7 @@ function defaultBackTarget(formId: string): string | null { case "create-encrypted": case "create-keystore": case "import-keystore": + case "import-mnemonic": case "decrypt": return "wallet-list"; case "unlock": @@ -1399,10 +1941,10 @@ function parseAddressList(value: string | number | undefined): string[] { export default function Home() { const t = useTranslations(); - const tf = (key: string, fallback: string, vars?: Record) => { + const tf = useCallback((key: string, fallback: string, vars?: Record) => { const value = t(key, vars); return value === key ? fallback : value; - }; + }, [t]); const menuItems: MenuItem[] = [ { @@ -1463,7 +2005,7 @@ export default function Home() { }, { id: "dapp-store", - label: tf("features.dapp-store.title", "DApp 应用"), + label: tf("features.dapp-store.title", "DApp Store"), icon: , network: true, }, @@ -1486,7 +2028,7 @@ export default function Home() { const [loading, setLoading] = useState(false); const [authMethod, setAuthMethod] = useState<{ [key: string]: "keystore" | "private" | "encrypted" }>({}); const [wallets, setWallets] = useState([]); - const [walletsLoading, setWalletsLoading] = useState(false); + const [walletsLoading, setWalletsLoading] = useState(true); const [walletsLoadError, setWalletsLoadError] = useState(null); const [workspace, setWorkspace] = useState(emptyWorkspace); const [selectedProgramProjectId, setSelectedProgramProjectId] = useState(""); @@ -1499,11 +2041,36 @@ export default function Home() { const [newRpcName, setNewRpcName] = useState(""); const [newRpcUrl, setNewRpcUrl] = useState(""); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false); + const [appTheme, setAppTheme] = useState("deep-sea"); const [walletActionsMenuOpen, setWalletActionsMenuOpen] = useState(null); const [walletFaucetMenuOpen, setWalletFaucetMenuOpen] = useState(false); + const [exportedPrivateKey, setExportedPrivateKey] = useState(null); + const [exportedMnemonic, setExportedMnemonic] = useState(null); + const [sensitiveExportTab, setSensitiveExportTab] = useState("private-key"); + const [exportBundleSelection, setExportBundleSelection] = useState({ + keystore: true, + "private-key": false, + mnemonic: false, + }); + const [privateKeyExportMode, setPrivateKeyExportMode] = useState("simple"); + const [privateKeySegmentCount, setPrivateKeySegmentCount] = useState(DEFAULT_PRIVATE_KEY_SEGMENTS); + const [privateKeyQrRevealed, setPrivateKeyQrRevealed] = useState(false); + const [dappTabs, setDappTabs] = useState([DAPP_HOME_TAB]); + const [activeDappTabId, setActiveDappTabId] = useState(DAPP_HOME_TAB_ID); const [dappSignRequest, setDappSignRequest] = useState(null); const [dappPassword, setDappPassword] = useState(""); + const [dappSaveBiometric, setDappSaveBiometric] = useState(false); + const [dappSearch, setDappSearch] = useState(""); + const [dappCategory, setDappCategory] = useState("trend"); const [dappSignBusy, setDappSignBusy] = useState(false); + const [dappTransactionPreview, setDappTransactionPreview] = useState(null); + const [dappTransactionPreviewError, setDappTransactionPreviewError] = useState(null); + const [dappTransactionPreviewLoading, setDappTransactionPreviewLoading] = useState(false); + const [dappPreviewDetailsOpen, setDappPreviewDetailsOpen] = useState(false); + const [biometricStatuses, setBiometricStatuses] = useState>({}); + const [biometricBusyWalletId, setBiometricBusyWalletId] = useState(null); + const [savePasswordToBiometric, setSavePasswordToBiometric] = useState(false); const [backTarget, setBackTarget] = useState(null); const [passwordPrompt, setPasswordPrompt] = useState(null); const [passwordPromptValue, setPasswordPromptValue] = useState(""); @@ -1571,10 +2138,19 @@ export default function Home() { const lastProgramDeploymentIntentRef = useRef(null); const programDeploymentJournalRef = useRef(emptyProgramDeploymentJournalState()); const programDeploymentLogPanelRef = useRef(null); + const privateKeyQrCanvasRef = useRef(null); + const dappBrowserShellRef = useRef(null); + const dappBrowserTabBarRef = useRef(null); + const dappBrowserAddressBarRef = useRef(null); + const dappBrowserViewportRef = useRef(null); const programDeploymentWatchdogTrippedRef = useRef(false); lastProgramDeploymentIntentRef.current = lastProgramDeploymentIntent; programDeploymentJournalRef.current = programDeploymentJournal; const passwordConfirmationInFlightRef = useRef(false); + const biometricPasswordPromptAttemptRef = useRef(""); + const biometricDappAttemptRef = useRef(""); + const approveDappSignRequestRef = useRef<((passwordOverride?: string) => Promise) | null>(null); + const confirmPasswordPromptRef = useRef<((passwordOverride?: string) => Promise) | null>(null); const [walletAssets, setWalletAssets] = useState(null); const [walletSolBalanceCache, setWalletSolBalanceCache] = useState>({}); const [walletTransactions, setWalletTransactions] = useState(null); @@ -1604,6 +2180,15 @@ export default function Home() { const walletAuth = (formId: string): WalletAuthTab => normalizeWalletAuth((authMethod[formId] ?? "keystore") as WalletAuthTab); + useEffect(() => { + setAppTheme(loadAppUiTheme()); + }, []); + + const selectAppTheme = useCallback((theme: AppUiTheme) => { + setAppTheme(theme); + saveAppUiTheme(theme); + }, []); + const clearPasswordPromptSecrets = useCallback(() => { setPasswordPromptValue(""); setMasterPasswordPromptValue(""); @@ -1672,6 +2257,155 @@ export default function Home() { createdByLabel: effectiveProgramWorkspaceOwnerLabel, }; + const biometricStatusFor = useCallback( + (wallet?: SavedWallet): BiometricWalletStatus | undefined => + wallet ? biometricStatuses[wallet.id] : undefined, + [biometricStatuses], + ); + + const supportsBiometricWallet = useCallback( + (wallet?: SavedWallet): boolean => + Boolean(wallet && isTauriWebview() && wallet.keystore_version !== "unknown"), + [], + ); + + const canUseBiometricWallet = useCallback( + (wallet?: SavedWallet): boolean => { + if (!supportsBiometricWallet(wallet)) return false; + const status = biometricStatusFor(wallet); + return status?.supported !== false; + }, + [biometricStatusFor, supportsBiometricWallet], + ); + + const biometricConfiguredFor = useCallback( + (wallet?: SavedWallet): boolean => + Boolean(supportsBiometricWallet(wallet) && biometricStatusFor(wallet)?.configured), + [biometricStatusFor, supportsBiometricWallet], + ); + + const refreshBiometricWalletStatus = useCallback(async (wallet: SavedWallet): Promise => { + if (!supportsBiometricWallet(wallet)) { + const status = { + supported: false, + configured: false, + reason: "Touch ID is available only for saved wallets in the desktop app.", + }; + setBiometricStatuses((prev) => ({ + ...prev, + [wallet.id]: status, + })); + return status; + } + try { + const status = await invoke("biometric_wallet_status", { + req: { + wallet_id: wallet.id, + public_key: wallet.public_key, + }, + }); + setBiometricStatuses((prev) => ({ ...prev, [wallet.id]: status })); + return status; + } catch (error) { + const status = { + supported: false, + configured: false, + reason: errorMessage(error, t("features.biometric.statusFailed")), + }; + setBiometricStatuses((prev) => { + const previous = prev[wallet.id]; + return { + ...prev, + [wallet.id]: previous?.configured + ? { + ...previous, + reason: status.reason, + } + : status, + }; + }); + return status; + } + }, [supportsBiometricWallet, t]); + + const storeBiometricWalletPassword = useCallback(async (wallet: SavedWallet, password: string) => { + if (!supportsBiometricWallet(wallet)) return false; + setBiometricBusyWalletId(wallet.id); + try { + await invoke("biometric_wallet_store_password", { + req: { + wallet_id: wallet.id, + public_key: wallet.public_key, + password, + }, + }); + setBiometricStatuses((prev) => ({ + ...prev, + [wallet.id]: { + supported: true, + configured: true, + reason: undefined, + }, + })); + toast.success(t("features.biometric.enabled")); + return true; + } catch (error) { + toast.error(errorMessage(error, t("features.biometric.enableFailed"))); + return false; + } finally { + setBiometricBusyWalletId(null); + } + }, [supportsBiometricWallet, t]); + + const getBiometricWalletPassword = useCallback(async (wallet: SavedWallet): Promise => { + if (!supportsBiometricWallet(wallet)) return null; + const cachedStatus = biometricStatusFor(wallet); + const status = + cachedStatus?.configured || cachedStatus?.supported === false + ? cachedStatus + : await refreshBiometricWalletStatus(wallet); + if (!status.supported || !status.configured) return null; + setBiometricBusyWalletId(wallet.id); + try { + return await invoke("biometric_wallet_get_password", { + req: { + wallet_id: wallet.id, + public_key: wallet.public_key, + }, + }); + } catch (error) { + toast.error(errorMessage(error, t("features.biometric.authFailed"))); + return null; + } finally { + setBiometricBusyWalletId(null); + } + }, [biometricStatusFor, refreshBiometricWalletStatus, supportsBiometricWallet, t]); + + const deleteBiometricWalletPassword = useCallback(async (wallet: SavedWallet, options: { quiet?: boolean } = {}) => { + if (!isTauriWebview()) return; + setBiometricBusyWalletId(wallet.id); + try { + await invoke("biometric_wallet_delete_password", { + req: { + wallet_id: wallet.id, + public_key: wallet.public_key, + }, + }); + setBiometricStatuses((prev) => ({ + ...prev, + [wallet.id]: { + ...(prev[wallet.id] ?? { supported: true }), + configured: false, + }, + })); + if (!options.quiet) toast.success(t("features.biometric.removed")); + } catch (error) { + if (!options.quiet) toast.error(errorMessage(error, t("features.biometric.removeFailed"))); + } finally { + setBiometricBusyWalletId(null); + } + }, [t]); + const scopedProgramProjectId = useCallback((sourceDir: string): string => programProjectId(sourceDir, effectiveProgramWorkspaceOwner), [effectiveProgramWorkspaceOwner]); @@ -1690,13 +2424,38 @@ export default function Home() { }; }, [effectiveNetwork, effectiveRpcRequest, effectiveWalletId]); + useEffect(() => { + if (!isTauriWebview()) return; + wallets + .filter((wallet) => wallet.keystore_version !== "unknown") + .forEach((wallet) => { + void refreshBiometricWalletStatus(wallet); + }); + }, [refreshBiometricWalletStatus, wallets]); + useEffect(() => { if (!isTauriWebview()) return; let unlisten: UnlistenFn | undefined; - void listen("dapp://sign-request", (event) => { - setDappSignRequest(event.payload); - setDappPassword(""); - toast.message("DApp 发起了交易签名请求"); + void listen("dapp://sign-request", (event) => { + void invoke("dapp_set_active_tab", { + tabId: null, + x: 0, + y: 0, + width: 0, + height: 0, + }).catch(() => {}); + setDappSignRequest(event.payload); + setDappPassword(""); + setDappSaveBiometric(false); + setDappTransactionPreview(null); + setDappTransactionPreviewError(null); + setDappTransactionPreviewLoading(event.payload.method !== "signMessage"); + setDappPreviewDetailsOpen(false); + toast.message( + event.payload.method === "signMessage" + ? tf("features.dapp-store.messageRequestToast", "DApp 发起了消息签名请求") + : tf("features.dapp-store.transactionRequestToast", "DApp 发起了交易签名请求"), + ); }).then((cleanup) => { unlisten = cleanup; }).catch(() => { @@ -1705,7 +2464,51 @@ export default function Home() { return () => { unlisten?.(); }; - }, []); + }, [tf]); + + useEffect(() => { + if (!dappSignRequest || dappSignRequest.method === "signMessage") { + setDappTransactionPreview(null); + setDappTransactionPreviewError(null); + setDappTransactionPreviewLoading(false); + return; + } + let cancelled = false; + setDappTransactionPreview(null); + setDappTransactionPreviewError(null); + setDappTransactionPreviewLoading(true); + void (async () => { + try { + const response = await apiFetch("external-sign/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + required_signer: dappSignRequest.wallet_public_key, + transaction_base64: dappSignRequest.transaction_base64, + transaction_format: dappSignRequest.transaction_format || "auto", + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || tf("features.dapp-store.previewFailed", "交易预览失败")); + } + if (!cancelled) { + setDappTransactionPreview(data as DappTransactionPreview); + } + } catch (error) { + if (!cancelled) { + setDappTransactionPreviewError(errorMessage(error, tf("features.dapp-store.previewFailed", "交易预览失败"))); + } + } finally { + if (!cancelled) { + setDappTransactionPreviewLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [dappSignRequest, tf]); const loadProgramDeploymentJournal = useCallback(async ( intent: ProgramDeploymentJournalIntent, @@ -3818,6 +4621,77 @@ export default function Home() { }); }; + const exportKeystoreWithPassword = async (wallet: SavedWallet, password: string) => { + const response = await apiFetch(`wallets/${wallet.id}/export`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || t("features.settings.exportFailed")); + } + void downloadFile( + data.keystore_json, + `${safeFilename(wallet.name)}-${wallet.public_key.slice(0, 8)}-keystore.json`, + ); + }; + + const exportPrivateKeyWithPassword = async ( + wallet: SavedWallet, + password: string, + ): Promise => { + const response = await apiFetch(`wallets/${wallet.id}/export-private-key`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || t("features.settings.exportPrivateKeyFailed")); + } + const privateKey = String(data.private_key || "").trim(); + if (!privateKey) { + throw new Error(t("features.settings.exportPrivateKeyFailed")); + } + return { + walletName: wallet.name, + publicKey: wallet.public_key, + privateKey, + }; + }; + + const exportMnemonicWithPassword = async ( + wallet: SavedWallet, + password: string, + ): Promise => { + const response = await apiFetch(`wallets/${wallet.id}/export-mnemonic`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || t("features.settings.exportMnemonicFailed")); + } + const mnemonic = String(data.mnemonic || "").trim(); + if (!mnemonic) { + throw new Error(t("features.settings.exportMnemonicFailed")); + } + return { + walletName: wallet.name, + publicKey: wallet.public_key, + mnemonic, + }; + }; + + const resetSensitiveExportDisplay = (tab: SensitiveExportKind = "private-key") => { + setPrivateKeyExportMode("simple"); + setPrivateKeySegmentCount(DEFAULT_PRIVATE_KEY_SEGMENTS); + setPrivateKeyQrRevealed(false); + setSensitiveExportTab(tab); + }; + const handleExportKeystore = async (wallet: SavedWallet, passwordValue: string) => { const password = passwordValue; if (password.length === 0) { @@ -3827,19 +4701,7 @@ export default function Home() { setLoading(true); try { - const response = await apiFetch(`wallets/${wallet.id}/export`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || t("features.settings.exportFailed")); - } - void downloadFile( - data.keystore_json, - `${safeFilename(wallet.name)}-${wallet.public_key.slice(0, 8)}-keystore.json`, - ); + await exportKeystoreWithPassword(wallet, password); toast.success(t("features.settings.exportSuccess")); } catch (err) { toast.error(err instanceof Error ? err.message : t("features.settings.exportFailed")); @@ -3857,30 +4719,10 @@ export default function Home() { setLoading(true); try { - const response = await apiFetch(`wallets/${wallet.id}/export-private-key`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || t("features.settings.exportPrivateKeyFailed")); - } - const content = [ - "Sol SafeKey plaintext private key export", - `Wallet: ${wallet.name}`, - `Public Key: ${wallet.public_key}`, - "", - "Private Key (base58):", - data.private_key, - "", - "Keep this file offline. Anyone with this private key can spend assets in this wallet.", - ].join("\n"); - void downloadFile( - content, - `${safeFilename(wallet.name)}-${wallet.public_key.slice(0, 8)}-private-key.txt`, - "text/plain", - ); + const privateKey = await exportPrivateKeyWithPassword(wallet, password); + resetSensitiveExportDisplay("private-key"); + setExportedMnemonic(null); + setExportedPrivateKey(privateKey); toast.success(t("features.settings.exportPrivateKeySuccess")); } catch (err) { toast.error(err instanceof Error ? err.message : t("features.settings.exportPrivateKeyFailed")); @@ -3889,14 +4731,82 @@ export default function Home() { } }; - const requestExportKeystore = (wallet: SavedWallet) => { - setPasswordPromptValue(""); - setPasswordPrompt({ kind: "export-keystore", wallet, formState: {} }); + const handleExportMnemonic = async (wallet: SavedWallet, passwordValue: string) => { + const password = passwordValue; + if (password.length === 0) { + toast.error(t("features.settings.exportPasswordRequired")); + return; + } + + setLoading(true); + try { + const mnemonic = await exportMnemonicWithPassword(wallet, password); + resetSensitiveExportDisplay("mnemonic"); + setExportedPrivateKey(null); + setExportedMnemonic(mnemonic); + toast.success(t("features.settings.exportMnemonicSuccess")); + } catch (err) { + toast.error(err instanceof Error ? err.message : t("features.settings.exportMnemonicFailed")); + } finally { + setLoading(false); + } + }; + + const handleExportBundle = async ( + wallet: SavedWallet, + passwordValue: string, + selection: ExportBundleSelection, + ) => { + const password = passwordValue; + if (password.length === 0) { + toast.error(t("features.settings.exportPasswordRequired")); + return; + } + if (!Object.values(selection).some(Boolean)) { + toast.error(t("features.settings.exportBundleSelectRequired")); + return; + } + + setLoading(true); + let privateKeyPreview: ExportedPrivateKeyPreview | null = null; + let mnemonicPreview: ExportedMnemonicPreview | null = null; + try { + if (selection.keystore) { + await exportKeystoreWithPassword(wallet, password); + } + if (selection["private-key"]) { + privateKeyPreview = await exportPrivateKeyWithPassword(wallet, password); + } + if (selection.mnemonic) { + mnemonicPreview = await exportMnemonicWithPassword(wallet, password); + } + + setExportedPrivateKey(privateKeyPreview); + setExportedMnemonic(mnemonicPreview); + if (privateKeyPreview || mnemonicPreview) { + resetSensitiveExportDisplay(privateKeyPreview ? "private-key" : "mnemonic"); + } + toast.success(t("features.settings.exportBundleSuccess")); + } catch (err) { + setExportedPrivateKey(privateKeyPreview); + setExportedMnemonic(mnemonicPreview); + if (privateKeyPreview || mnemonicPreview) { + resetSensitiveExportDisplay(privateKeyPreview ? "private-key" : "mnemonic"); + } + toast.error(err instanceof Error ? err.message : t("features.settings.exportBundleFailed")); + } finally { + setLoading(false); + } }; - const requestExportPrivateKey = (wallet: SavedWallet) => { + const requestExportBundle = (wallet: SavedWallet) => { setPasswordPromptValue(""); - setPasswordPrompt({ kind: "export-private-key", wallet, formState: {} }); + setExportBundleSelection({ + keystore: true, + "private-key": false, + mnemonic: false, + }); + setPasswordPrompt({ kind: "export-bundle", wallet, formState: {} }); }; const handleMigrateKeystore = async ( @@ -3936,6 +4846,13 @@ export default function Home() { setPasswordPrompt({ kind: "migrate-keystore", wallet, formState: {} }); }; + const requestEnableBiometricWallet = (wallet: SavedWallet) => { + setPasswordPromptValue(""); + setMasterPasswordPromptValue(""); + setSavePasswordToBiometric(false); + setPasswordPrompt({ kind: "enable-biometric", wallet, formState: { wallet_id: wallet.id } }); + }; + const handleDeleteWallet = async (wallet: SavedWallet) => { const confirmed = window.confirm( t("features.settings.deleteWalletConfirm", { name: wallet.name }), @@ -3966,6 +4883,7 @@ export default function Home() { return next; }); setEditingWalletId((prev) => (prev === wallet.id ? null : prev)); + await deleteBiometricWalletPassword(wallet, { quiet: true }); await loadWallets(); toast.success(t("features.settings.deleteWalletSuccess")); } catch (err) { @@ -3980,6 +4898,19 @@ export default function Home() { setActiveMenu(activeMenu === menuId ? null : menuId); }; + const handleDesktopMenuClick = (item: MenuItem) => { + if (desktopSidebarCollapsed && item.children?.length) { + setDesktopSidebarCollapsed(false); + setActiveMenu(item.id); + return; + } + if (item.children) { + toggleMenu(item.id); + } else { + handleSelectForm(item.id); + } + }; + const openParentMenuForForm = (formId: string) => { const menu = menuItems.find((item) => item.id === formId && item.children?.length); if (menu) { @@ -4303,31 +5234,327 @@ export default function Home() { } }; + const activeDappTab = dappTabs.find((tab) => tab.id === activeDappTabId) ?? dappTabs[0] ?? DAPP_HOME_TAB; + const dappSearchTerm = dappSearch.trim().toLowerCase(); + const dappStoreVisibleDapps = SOLANA_DAPP_CATALOG.filter((dapp) => { + const matchesCategory = dappCategory === "trend" || dapp.category === dappCategory; + if (!matchesCategory) return false; + if (!dappSearchTerm) return true; + const haystack = [dapp.name, dapp.description, dapp.category, dapp.chain, ...dapp.tags] + .join(" ") + .toLowerCase(); + return haystack.includes(dappSearchTerm); + }); + const dappStoreFeaturedDapps = SOLANA_DAPP_CATALOG.slice(0, 3); + + const dappBrowserBounds = useCallback(() => { + const shell = dappBrowserShellRef.current; + if (!shell) return null; + const rect = shell.getBoundingClientRect(); + const measuredTabBarHeight = Math.ceil(dappBrowserTabBarRef.current?.getBoundingClientRect().height ?? 0); + const measuredAddressBarHeight = Math.ceil(dappBrowserAddressBarRef.current?.getBoundingClientRect().height ?? 0); + const tabBarHeight = Math.max(measuredTabBarHeight, 40); + const addressBarHeight = activeDappTab.showAddressBar ? Math.max(measuredAddressBarHeight, 56) : 0; + const chromeHeight = tabBarHeight + addressBarHeight; + const nativeTopOffset = dappNativeWindowTopOffset(); + const width = Math.max(0, Math.floor(rect.width)); + const height = Math.max(0, Math.floor(rect.height - chromeHeight)); + if (width < 40 || height < 40) return null; + return { + x: Math.round(rect.left), + y: Math.round(rect.top + chromeHeight + nativeTopOffset), + width, + height, + }; + }, [activeDappTab.showAddressBar]); + + const setActiveNativeDappTab = useCallback(async () => { + if (!isTauriWebview()) return; + const tab = dappTabs.find((item) => item.id === activeDappTabId); + const bounds = dappBrowserBounds(); + const shouldShowNativeTab = + selectedForm === "dapp-store" && + !dappSignRequest && + Boolean(tab?.webviewOpen) && + !tab?.loading && + Boolean(bounds); + try { + await invoke("dapp_set_active_tab", { + tabId: shouldShowNativeTab ? activeDappTabId : null, + x: bounds?.x ?? 0, + y: bounds?.y ?? 0, + width: bounds?.width ?? 0, + height: bounds?.height ?? 0, + }); + } catch { + // A tab can be closed while React is still settling layout; the next sync will correct it. + } + }, [activeDappTabId, dappBrowserBounds, dappSignRequest, dappTabs, selectedForm]); + + const createDappWebview = useCallback(async ( + tabId: string, + url: string, + dapp?: DappCatalogItem, + ) => { + if (!isTauriWebview()) { + toast.error(tf("features.dapp-store.tauriOnly", "DApp 自动连接需要在桌面客户端中使用。")); + return; + } + const bounds = dappBrowserBounds(); + if (!bounds) { + requestAnimationFrame(() => { + void createDappWebview(tabId, url, dapp); + }); + return; + } + const wallet = dapp ? effectiveWallet : undefined; + if (dapp && !wallet) { + toast.error(tf("features.dapp-store.noWallet", "请先选择一个钱包。")); + return; + } + setDappTabs((tabs) => tabs.map((tab) => + tab.id === tabId ? { ...tab, webviewOpen: true, loading: true } : tab, + )); + try { + await invoke("dapp_open_tab", { + tabId, + url, + appId: dapp?.id ?? null, + walletPublicKey: wallet?.public_key ?? null, + network: effectiveRpcRequest, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }); + setDappTabs((tabs) => tabs.map((tab) => + tab.id === tabId + ? { + ...tab, + url, + addressInput: url, + appId: dapp?.id, + walletConnected: Boolean(dapp && wallet), + webviewOpen: true, + } + : tab, + )); + } catch (error) { + setDappTabs((tabs) => tabs.map((tab) => + tab.id === tabId ? { ...tab, webviewOpen: false, loading: false } : tab, + )); + toast.error(errorMessage(error, tf("features.dapp-store.openFailed", "打开 DApp 失败"))); + } + }, [dappBrowserBounds, effectiveRpcRequest, effectiveWallet, tf]); + + const openUrlInDappTab = useCallback((rawUrl: string, options: { dapp?: DappCatalogItem; tabId?: string; showAddressBar?: boolean } = {}) => { + let url: string; + try { + url = normalizeDappBrowserUrl(rawUrl); + } catch (error) { + toast.error(errorMessage(error, "网址格式不正确")); + return; + } + const matchedDapp = options.dapp ?? (effectiveWallet ? dappForUrl(url) : undefined); + const tabId = options.tabId ?? newDappTabId(); + const title = matchedDapp?.name ?? new URL(url).hostname; + setDappTabs((tabs) => { + const existing = tabs.find((tab) => tab.id === tabId); + if (existing) { + return tabs.map((tab) => + tab.id === tabId + ? { ...tab, title, url, addressInput: url, appId: matchedDapp?.id, walletConnected: false, loading: true } + : tab, + ); + } + return [ + ...tabs, + { + id: tabId, + title, + url, + addressInput: url, + closable: true, + showAddressBar: Boolean(options.showAddressBar), + appId: matchedDapp?.id, + walletConnected: false, + webviewOpen: false, + loading: true, + }, + ]; + }); + setActiveDappTabId(tabId); + requestAnimationFrame(() => { + void createDappWebview(tabId, url, matchedDapp); + }); + }, [createDappWebview, effectiveWallet]); + const openDapp = async (dapp: DappCatalogItem) => { const wallet = effectiveWallet; if (!wallet) { toast.error(tf("features.dapp-store.noWallet", "请先选择一个钱包。")); return; } - if (!isTauriWebview()) { - toast.error(tf("features.dapp-store.tauriOnly", "DApp 自动连接需要在桌面客户端中使用。")); + openUrlInDappTab(dapp.url, { dapp }); + }; + + const addBlankDappTab = () => { + const tabId = newDappTabId(); + setDappTabs((tabs) => [ + ...tabs, + { + id: tabId, + title: "新标签页", + url: "", + addressInput: "", + closable: true, + showAddressBar: true, + walletConnected: false, + webviewOpen: false, + loading: false, + }, + ]); + setActiveDappTabId(tabId); + }; + + const closeDappTab = (tabId: string) => { + const tabIndex = dappTabs.findIndex((tab) => tab.id === tabId); + const tab = dappTabs[tabIndex]; + if (!tab?.closable) return; + const nextTabs = dappTabs.filter((item) => item.id !== tabId); + setDappTabs(nextTabs); + if (activeDappTabId === tabId) { + setActiveDappTabId(nextTabs[Math.max(0, tabIndex - 1)]?.id ?? DAPP_HOME_TAB_ID); + } + if (isTauriWebview()) { + void invoke("dapp_close_tab", { tabId }).catch(() => {}); + } + }; + + const submitDappAddress = (event: FormEvent) => { + event.preventDefault(); + const tab = activeDappTab; + if (!tab) return; + const value = tab.addressInput.trim(); + if (!value) return; + if (tab.id === DAPP_HOME_TAB_ID || !tab.webviewOpen) { + openUrlInDappTab(value, { + tabId: tab.id === DAPP_HOME_TAB_ID ? undefined : tab.id, + showAddressBar: tab.showAddressBar, + }); return; } + let url: string; try { - await invoke("open_dapp_window", { - app_id: dapp.id, - wallet_public_key: wallet.public_key, - network: effectiveRpcRequest, - }); - toast.success(tf("features.dapp-store.opened", "DApp 已打开")); + url = normalizeDappBrowserUrl(value); } catch (error) { - toast.error(error instanceof Error ? error.message : tf("features.dapp-store.openFailed", "打开 DApp 失败")); + toast.error(errorMessage(error, "网址格式不正确")); + return; } + setDappTabs((tabs) => tabs.map((item) => + item.id === tab.id ? { ...item, loading: true } : item, + )); + void invoke("dapp_navigate_tab", { tabId: tab.id, url }) + .then(() => { + setDappTabs((tabs) => tabs.map((item) => + item.id === tab.id ? { ...item, url, addressInput: url, title: new URL(url).hostname } : item, + )); + }) + .catch((error) => { + setDappTabs((tabs) => tabs.map((item) => + item.id === tab.id ? { ...item, loading: false } : item, + )); + toast.error(errorMessage(error, tf("features.dapp-store.openFailed", "打开 DApp 失败"))); + }); }; + useEffect(() => { + if (!isTauriWebview()) return; + const unlisteners: UnlistenFn[] = []; + let cancelled = false; + Promise.all([ + listen("dapp://tab-url", (event) => { + setDappTabs((tabs) => tabs.map((tab) => + tab.id === event.payload.tab_id + ? { + ...tab, + url: event.payload.url, + addressInput: event.payload.url, + loading: event.payload.loaded ? false : tab.loading, + } + : tab, + )); + }), + listen("dapp://tab-title", (event) => { + setDappTabs((tabs) => tabs.map((tab) => + tab.id === event.payload.tab_id + ? { ...tab, title: event.payload.title || tab.title } + : tab, + )); + }), + listen("dapp://new-window", (event) => { + openUrlInDappTab(event.payload.url); + }), + ]).then((cleanups) => { + if (cancelled) { + cleanups.forEach((cleanup) => cleanup()); + } else { + unlisteners.push(...cleanups); + } + }).catch(() => { + // The web build has no Tauri event bridge. + }); + return () => { + cancelled = true; + unlisteners.forEach((cleanup) => cleanup()); + }; + }, [openUrlInDappTab]); + + useEffect(() => { + void setActiveNativeDappTab(); + }, [setActiveNativeDappTab]); + + useEffect(() => { + if (!isTauriWebview()) return; + const timers = [60, 180, 360].map((delay) => + window.setTimeout(() => { + void setActiveNativeDappTab(); + }, delay), + ); + return () => { + timers.forEach((timer) => window.clearTimeout(timer)); + }; + }, [activeDappTabId, activeDappTab.showAddressBar, selectedForm, setActiveNativeDappTab]); + + useEffect(() => { + if (!isTauriWebview()) return; + const sync = () => { + void setActiveNativeDappTab(); + }; + const observedElements = [ + dappBrowserShellRef.current, + dappBrowserTabBarRef.current, + dappBrowserAddressBarRef.current, + dappBrowserViewportRef.current, + ].filter((element): element is HTMLDivElement => Boolean(element)); + const observer = typeof ResizeObserver !== "undefined" && observedElements.length > 0 + ? new ResizeObserver(sync) + : null; + observedElements.forEach((element) => observer?.observe(element)); + window.addEventListener("resize", sync); + window.addEventListener("scroll", sync, true); + const frame = requestAnimationFrame(sync); + return () => { + observer?.disconnect(); + window.removeEventListener("resize", sync); + window.removeEventListener("scroll", sync, true); + cancelAnimationFrame(frame); + }; + }, [setActiveNativeDappTab]); + const resolveDappSignRequest = async (request: DappSignRequestEvent, result: DappSignResult) => { await invoke("resolve_dapp_sign_request", { - request_id: request.request_id, + requestId: request.request_id, result, }); }; @@ -4341,16 +5568,21 @@ export default function Home() { approved: false, error: "用户拒绝了 DApp 交易签名请求", }); - setDappSignRequest(null); - setDappPassword(""); - } catch (error) { - toast.error(error instanceof Error ? error.message : "拒绝 DApp 请求失败"); + setDappSignRequest(null); + setDappPassword(""); + setDappSaveBiometric(false); + setDappTransactionPreview(null); + setDappTransactionPreviewError(null); + setDappTransactionPreviewLoading(false); + setDappPreviewDetailsOpen(false); + } catch (error) { + toast.error(errorMessage(error, "拒绝 DApp 请求失败")); } finally { setDappSignBusy(false); } }; - const approveDappSignRequest = async () => { + const approveDappSignRequest = async (passwordOverride?: string) => { const request = dappSignRequest; if (!request) return; const wallet = wallets.find((item) => item.public_key === request.wallet_public_key); @@ -4358,26 +5590,49 @@ export default function Home() { toast.error(tf("features.dapp-store.walletMissing", "这个请求指定的钱包不在当前钱包列表中。")); return; } - if (!dappPassword) { + const walletPassword = passwordOverride ?? dappPassword; + if (!walletPassword) { toast.error(t("formUi.placeholderKeystorePassword")); return; } - const shouldSubmit = request.method === "sendTransaction" || request.method === "signAndSendTransaction"; + const isMessageSignature = request.method === "signMessage"; + if (!isMessageSignature) { + if (dappTransactionPreviewLoading) { + toast.error(tf("features.dapp-store.previewLoading", "交易预览仍在加载,请稍后再确认")); + return; + } + if (dappTransactionPreviewError || !dappTransactionPreview) { + toast.error(dappTransactionPreviewError || tf("features.dapp-store.previewRequired", "需要先完成交易预览")); + return; + } + if (!dappTransactionPreview.required_signer_present) { + toast.error(tf("features.dapp-store.signerMissing", "交易没有要求当前钱包签名,已拒绝")); + return; + } + } + const shouldSubmit = request.method === "sendTransaction" || request.method === "signAndSendTransaction"; setDappSignBusy(true); try { - const response = await apiFetch(shouldSubmit ? "external-sign/submit" : "external-sign/sign", { + const response = await apiFetch( + isMessageSignature ? "external-sign/message" : shouldSubmit ? "external-sign/submit" : "external-sign/sign", + { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ wallet_id: wallet.id, - password: dappPassword, + password: walletPassword, required_signer: request.wallet_public_key, - transaction_base64: request.transaction_base64, - transaction_format: request.transaction_format || "auto", + ...(isMessageSignature + ? { message_base64: request.message_base64 || "" } + : { + transaction_base64: request.transaction_base64, + transaction_format: request.transaction_format || "auto", + }), network: request.network || effectiveRpcRequest, request_id: request.request_id, }), - }); + }, + ); const data = await response.json(); if (!response.ok) { throw new Error(data.error || tf("features.dapp-store.signFailed", "DApp 交易签名失败")); @@ -4389,21 +5644,94 @@ export default function Home() { recent_blockhash: String(data.recent_blockhash || data.recentBlockhash || "").trim() || undefined, }; await resolveDappSignRequest(request, result); + if (!passwordOverride && dappSaveBiometric && supportsBiometricWallet(wallet)) { + await storeBiometricWalletPassword(wallet, walletPassword); + } toast.success( - shouldSubmit + isMessageSignature + ? tf("features.dapp-store.messageSignSuccess", "DApp 消息已签名") + : shouldSubmit ? tf("features.dapp-store.submitSuccess", "DApp 交易已提交") : tf("features.dapp-store.signSuccess", "DApp 交易已签名"), ); - setDappSignRequest(null); - setDappPassword(""); - refreshWalletAfterMutation(wallet); + setDappSignRequest(null); + setDappPassword(""); + setDappSaveBiometric(false); + setDappTransactionPreview(null); + setDappTransactionPreviewError(null); + setDappTransactionPreviewLoading(false); + setDappPreviewDetailsOpen(false); + if (!isMessageSignature) refreshWalletAfterMutation(wallet); } catch (error) { - toast.error(error instanceof Error ? error.message : tf("features.dapp-store.signFailed", "DApp 交易签名失败")); + toast.error( + errorMessage( + error, + isMessageSignature + ? tf("features.dapp-store.messageSignFailed", "DApp 消息签名失败") + : tf("features.dapp-store.signFailed", "DApp 交易签名失败"), + ), + ); } finally { setDappSignBusy(false); } }; + const approveDappSignRequestWithBiometric = async () => { + const request = dappSignRequest; + if (!request) return; + const wallet = wallets.find((item) => item.public_key === request.wallet_public_key); + if (!wallet) { + toast.error(tf("features.dapp-store.walletMissing", "这个请求指定的钱包不在当前钱包列表中。")); + return; + } + const password = await getBiometricWalletPassword(wallet); + if (password) { + await approveDappSignRequest(password); + } + }; + approveDappSignRequestRef.current = approveDappSignRequest; + + useEffect(() => { + if (!dappSignRequest) { + biometricDappAttemptRef.current = ""; + return; + } + const wallet = wallets.find((item) => item.public_key === dappSignRequest.wallet_public_key); + if (!wallet || !canUseBiometricWallet(wallet)) { + return; + } + if (dappSignBusy || biometricBusyWalletId === wallet.id) { + return; + } + if (dappSignRequest.method !== "signMessage") { + if (dappTransactionPreviewLoading || dappTransactionPreviewError || !dappTransactionPreview) { + return; + } + if (!dappTransactionPreview.required_signer_present) { + return; + } + } + const attemptKey = `${dappSignRequest.request_id}:${wallet.id}`; + if (biometricDappAttemptRef.current === attemptKey) return; + biometricDappAttemptRef.current = attemptKey; + void (async () => { + const password = await getBiometricWalletPassword(wallet); + if (password) { + await approveDappSignRequestRef.current?.(password); + } + })(); + }, [ + biometricBusyWalletId, + canUseBiometricWallet, + dappSignBusy, + dappSignRequest, + dappTransactionPreview, + dappTransactionPreviewError, + dappTransactionPreviewLoading, + getBiometricWalletPassword, + wallets, + ]); + const clearForm = () => { if (selectedForm === "program-deploy") { resetProgramDeploySession(); @@ -5962,6 +7290,10 @@ export default function Home() { toast.error(t("features.import-keystore.fillAllFields")); return; } + if (formId === "import-mnemonic" && !String(nextFormData.mnemonic ?? "").trim()) { + toast.error(t("features.import-mnemonic.fillAllFields")); + return; + } setPasswordPromptValue(""); setMasterPasswordPromptValue(""); @@ -6023,6 +7355,7 @@ export default function Home() { setFormData(nextFormData); setPasswordPromptValue(""); setMasterPasswordPromptValue(""); + setSavePasswordToBiometric(false); setPasswordPrompt({ kind: "form", formId, @@ -6052,6 +7385,7 @@ export default function Home() { setFormData(nextFormData); setPasswordPromptValue(""); setMasterPasswordPromptValue(""); + setSavePasswordToBiometric(false); setPasswordPrompt({ kind: "proposal", proposal, action, formState: nextFormData }); }; @@ -6061,6 +7395,7 @@ export default function Home() { clearProgramKeypairMaterial(); } setPasswordPrompt(null); + setSavePasswordToBiometric(false); clearPasswordPromptSecrets(); }; @@ -6072,7 +7407,21 @@ export default function Home() { const showWalletPasswordPrompt = passwordPromptFields.includes("password"); const showMasterPasswordPrompt = passwordPromptFields.includes("master_password"); const showMigrationPasswords = passwordPrompt?.kind === "migrate-keystore"; + const showExportBundlePrompt = passwordPrompt?.kind === "export-bundle"; const passwordPromptIsBusy = loading || passwordConfirmationBusy; + const passwordPromptWallet = + passwordPrompt && "formState" in passwordPrompt + ? savedWalletFromForm(passwordPrompt.formState) + : undefined; + const showPasswordPromptBiometric = + showWalletPasswordPrompt && + !showMigrationPasswords && + passwordPrompt?.kind !== "create-password" && + Boolean(passwordPromptWallet) && + canUseBiometricWallet(passwordPromptWallet); + const passwordPromptBiometricConfigured = biometricConfiguredFor(passwordPromptWallet); + const passwordPromptBiometricBusy = + Boolean(passwordPromptWallet && biometricBusyWalletId === passwordPromptWallet.id); const isProgramDeploymentPasswordPrompt = passwordPrompt?.kind === "form" && passwordPrompt.formId === "program-deploy"; const isProgramUpgradePasswordPrompt = @@ -6081,10 +7430,16 @@ export default function Home() { isProgramDeploymentPasswordPrompt || isProgramUpgradePasswordPrompt; const passwordPromptTitle = - passwordPrompt?.kind === "export-keystore" + passwordPrompt?.kind === "export-bundle" + ? t("features.settings.exportBundleTitle") + : passwordPrompt?.kind === "export-keystore" ? t("features.settings.exportPasswordTitle") : passwordPrompt?.kind === "export-private-key" ? t("features.settings.exportPrivateKeyTitle") + : passwordPrompt?.kind === "export-mnemonic" + ? t("features.settings.exportMnemonicTitle") + : passwordPrompt?.kind === "enable-biometric" + ? t("features.biometric.enableTitle") : passwordPrompt?.kind === "migrate-keystore" ? t("features.settings.migrateTitle") : passwordPrompt?.kind === "create-password" @@ -6097,10 +7452,16 @@ export default function Home() { ? t("formUi.masterPasswordPromptTitle") : t("formUi.confirmPasswordTitle"); const passwordPromptHint = - passwordPrompt?.kind === "export-keystore" + passwordPrompt?.kind === "export-bundle" + ? t("features.settings.exportBundleHint") + : passwordPrompt?.kind === "export-keystore" ? t("features.settings.exportPasswordHint") : passwordPrompt?.kind === "export-private-key" ? t("features.settings.exportPrivateKeyHint") + : passwordPrompt?.kind === "export-mnemonic" + ? t("features.settings.exportMnemonicHint") + : passwordPrompt?.kind === "enable-biometric" + ? t("features.biometric.enableHint") : passwordPrompt?.kind === "migrate-keystore" ? t("features.settings.migrateHint") : passwordPrompt?.kind === "create-password" @@ -6113,10 +7474,16 @@ export default function Home() { ? t("formUi.masterPasswordPromptHint") : t("formUi.confirmPasswordHint"); const passwordPromptButton = - passwordPrompt?.kind === "export-keystore" + passwordPrompt?.kind === "export-bundle" + ? t("features.settings.exportBundleButton") + : passwordPrompt?.kind === "export-keystore" ? t("features.settings.exportPasswordButton") : passwordPrompt?.kind === "export-private-key" ? t("features.settings.exportPrivateKeyButton") + : passwordPrompt?.kind === "export-mnemonic" + ? t("features.settings.exportMnemonicButton") + : passwordPrompt?.kind === "enable-biometric" + ? t("features.biometric.enableButton") : passwordPrompt?.kind === "migrate-keystore" ? t("features.settings.migrateButton") : passwordPrompt?.kind === "create-password" @@ -6216,9 +7583,30 @@ export default function Home() { return true; }; - const confirmPasswordPrompt = async () => { + const verifyAndStoreBiometricWalletPassword = async ( + wallet: SavedWallet | undefined, + password: string, + ): Promise => { + if (!savePasswordToBiometric || !wallet || !supportsBiometricWallet(wallet)) return true; + const response = await apiFetch("wallet/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + wallet_id: wallet.id, + password, + }), + }); + const data = await response.json(); + if (!response.ok || String(data.public_key || "").trim() !== wallet.public_key) { + toast.error(data.error || t("features.biometric.passwordInvalid")); + return false; + } + return storeBiometricWalletPassword(wallet, password); + }; + + const confirmPasswordPrompt = async (passwordOverride?: string) => { if (!passwordPrompt || passwordConfirmationInFlightRef.current) return; - const password = passwordPromptValue; + const password = passwordOverride ?? passwordPromptValue; const masterPassword = masterPasswordPromptValue; if (showWalletPasswordPrompt && password.length === 0) { toast.error(t("formUi.confirmPasswordRequired")); @@ -6273,6 +7661,11 @@ export default function Home() { const passwordOk = await validateProgramInvokeWalletPassword(nextFormData, passwordPrompt.formId); if (!passwordOk) return; } + const biometricStored = await verifyAndStoreBiometricWalletPassword( + savedWalletFromForm(nextFormData), + password, + ); + if (!biometricStored) return; await handleSubmit(passwordPrompt.formId, nextFormData); } else if (passwordPrompt.kind === "create-password") { await handleSubmit(passwordPrompt.formId, { ...passwordPrompt.formState, password }); @@ -6283,11 +7676,37 @@ export default function Home() { }); } else if (passwordPrompt.kind === "proposal") { const nextFormData = walletAuthFormData({ ...passwordPrompt.formState, password }); + const biometricStored = await verifyAndStoreBiometricWalletPassword( + savedWalletFromForm(nextFormData), + password, + ); + if (!biometricStored) return; await handleWorkspaceProposalAction(passwordPrompt.proposal, passwordPrompt.action, nextFormData); + } else if (passwordPrompt.kind === "enable-biometric") { + const wallet = passwordPrompt.wallet; + const response = await apiFetch("wallet/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + wallet_id: wallet.id, + password, + }), + }); + const data = await response.json(); + if (!response.ok || String(data.public_key || "").trim() !== wallet.public_key) { + toast.error(data.error || t("features.biometric.passwordInvalid")); + return; + } + const biometricStored = await storeBiometricWalletPassword(wallet, password); + if (!biometricStored) return; + } else if (passwordPrompt.kind === "export-bundle") { + await handleExportBundle(passwordPrompt.wallet, password, exportBundleSelection); } else if (passwordPrompt.kind === "export-keystore") { await handleExportKeystore(passwordPrompt.wallet, password); } else if (passwordPrompt.kind === "export-private-key") { await handleExportPrivateKey(passwordPrompt.wallet, password); + } else if (passwordPrompt.kind === "export-mnemonic") { + await handleExportMnemonic(passwordPrompt.wallet, password); } else { await handleMigrateKeystore(passwordPrompt.wallet, password, migrationNewPassword); } @@ -6300,11 +7719,50 @@ export default function Home() { delete next.master_password; return next; }); + setSavePasswordToBiometric(false); } finally { passwordConfirmationInFlightRef.current = false; setPasswordConfirmationBusy(false); } }; + confirmPasswordPromptRef.current = confirmPasswordPrompt; + + useEffect(() => { + if (!passwordPrompt) { + biometricPasswordPromptAttemptRef.current = ""; + return; + } + if ( + !passwordPromptWallet || + !showPasswordPromptBiometric || + passwordPromptIsBusy || + passwordPromptBiometricBusy + ) { + return; + } + const promptKind = + passwordPrompt.kind === "form" + ? `${passwordPrompt.kind}:${passwordPrompt.formId}` + : passwordPrompt.kind === "proposal" + ? `${passwordPrompt.kind}:${passwordPrompt.action}:${passwordPrompt.proposal.address}:${passwordPrompt.proposal.transactionIndex}` + : passwordPrompt.kind; + const attemptKey = `${promptKind}:${passwordPromptWallet.id}`; + if (biometricPasswordPromptAttemptRef.current === attemptKey) return; + biometricPasswordPromptAttemptRef.current = attemptKey; + void (async () => { + const password = await getBiometricWalletPassword(passwordPromptWallet); + if (password) { + await confirmPasswordPromptRef.current?.(password); + } + })(); + }, [ + getBiometricWalletPassword, + passwordPrompt, + passwordPromptBiometricBusy, + passwordPromptIsBusy, + passwordPromptWallet, + showPasswordPromptBiometric, + ]); const handleBack = () => { const target = backTarget || (selectedForm ? defaultBackTarget(selectedForm) : null); @@ -6394,6 +7852,126 @@ export default function Home() { toast.success(t("common.downloaded", { filename })); }; + const sensitiveExportPreviews: SensitiveExportPreview[] = [ + exportedPrivateKey + ? { + kind: "private-key", + walletName: exportedPrivateKey.walletName, + publicKey: exportedPrivateKey.publicKey, + value: exportedPrivateKey.privateKey, + title: t("features.settings.exportPrivateKeyQrTitle"), + hint: t("features.settings.exportPrivateKeyQrHint"), + warning: t("features.settings.exportPrivateKeyQrWarning"), + qrCanvasLabel: t("features.settings.exportPrivateKeyQrCanvas"), + plaintextLabel: t("features.settings.exportPrivateKeyPlaintextLabel"), + temporaryHint: t("features.settings.exportPrivateKeyTemporaryHint"), + hiddenLabel: t("features.settings.exportPrivateKeyHidden"), + segmentsTitle: t("features.settings.exportPrivateKeySegmentsTitle"), + segmentsHelp: t("features.settings.exportPrivateKeySegmentsHelp"), + segmentsHint: t("features.settings.exportPrivateKeySegmentsHint"), + copyLabel: t("features.settings.exportPrivateKeyCopy"), + downloadLabel: t("features.settings.exportPrivateKeyDownloadPlaintext"), + downloadSuffix: "private-key", + copyId: "export-private-key", + segmentCopyPrefix: "export-private-key-segment", + qrFailedMessage: t("features.settings.exportPrivateKeyQrFailed"), + } + : null, + exportedMnemonic + ? { + kind: "mnemonic", + walletName: exportedMnemonic.walletName, + publicKey: exportedMnemonic.publicKey, + value: exportedMnemonic.mnemonic, + title: t("features.settings.exportMnemonicQrTitle"), + hint: t("features.settings.exportMnemonicQrHint"), + warning: t("features.settings.exportMnemonicQrWarning"), + qrCanvasLabel: t("features.settings.exportMnemonicQrCanvas"), + plaintextLabel: t("features.settings.exportMnemonicPlaintextLabel"), + temporaryHint: t("features.settings.exportMnemonicTemporaryHint"), + hiddenLabel: t("features.settings.exportMnemonicHidden"), + segmentsTitle: t("features.settings.exportMnemonicSegmentsTitle"), + segmentsHelp: t("features.settings.exportMnemonicSegmentsHelp"), + segmentsHint: t("features.settings.exportMnemonicSegmentsHint"), + copyLabel: t("features.settings.exportMnemonicCopy"), + downloadLabel: t("features.settings.exportMnemonicDownloadPlaintext"), + downloadSuffix: "mnemonic", + copyId: "export-mnemonic", + segmentCopyPrefix: "export-mnemonic-segment", + qrFailedMessage: t("features.settings.exportMnemonicQrFailed"), + } + : null, + ].filter((preview): preview is SensitiveExportPreview => Boolean(preview)); + const sensitiveExport = + sensitiveExportPreviews.find((preview) => preview.kind === sensitiveExportTab) || + sensitiveExportPreviews[0] || + null; + const sensitiveExportValue = sensitiveExport?.value ?? null; + + const sensitiveExportContent = (preview: SensitiveExportPreview): string => [ + preview.kind === "mnemonic" + ? "FnzeroSafe plaintext mnemonic export" + : "FnzeroSafe plaintext private key export", + `Wallet: ${preview.walletName}`, + `Public Key: ${preview.publicKey}`, + "", + preview.kind === "mnemonic" ? "Mnemonic phrase:" : "Private Key (base58):", + preview.value, + "", + preview.kind === "mnemonic" + ? "Keep this file offline. Anyone with this mnemonic phrase can restore and spend assets in this wallet." + : "Keep this file offline. Anyone with this private key can spend assets in this wallet.", + ].join("\n"); + + const clearPrivateKeyQrCanvas = useCallback(() => { + const canvas = privateKeyQrCanvasRef.current; + if (!canvas) return; + const context = canvas.getContext("2d"); + context?.clearRect(0, 0, canvas.width, canvas.height); + }, []); + + const closeSensitiveExport = useCallback(() => { + clearPrivateKeyQrCanvas(); + setExportedPrivateKey(null); + setExportedMnemonic(null); + setPrivateKeyExportMode("simple"); + setPrivateKeySegmentCount(DEFAULT_PRIVATE_KEY_SEGMENTS); + setPrivateKeyQrRevealed(false); + setSensitiveExportTab("private-key"); + }, [clearPrivateKeyQrCanvas]); + + const changePrivateKeySegmentCount = (delta: number) => { + setPrivateKeySegmentCount((current) => clampPrivateKeySegmentCount(current + delta)); + }; + + useEffect(() => { + const canvas = privateKeyQrCanvasRef.current; + if (!privateKeyQrRevealed) { + clearPrivateKeyQrCanvas(); + return; + } + if (!canvas || !sensitiveExportValue) return; + let cancelled = false; + void QRCode.toCanvas(canvas, sensitiveExportValue, { + width: 280, + margin: 2, + errorCorrectionLevel: "M", + color: { + dark: "#0f172a", + light: "#ffffff", + }, + }).catch(() => { + if (!cancelled) { + toast.error(sensitiveExport?.qrFailedMessage ?? t("features.settings.exportPrivateKeyQrFailed")); + } + }); + + return () => { + cancelled = true; + clearPrivateKeyQrCanvas(); + }; + }, [clearPrivateKeyQrCanvas, privateKeyQrRevealed, sensitiveExport?.qrFailedMessage, sensitiveExportValue, t]); + const openDownloadLocation = async (path: string) => { try { await invoke("open_download_file_location", { path }); @@ -6655,6 +8233,9 @@ export default function Home() { toast.error(t("features.program-deploy.sourceDirRequired")); return; } + if (build && !window.confirm(t("features.program-deploy.sourceBuildConfirm"))) { + return; + } setProgramSourceLoading(true); setProgramDeployInlineError(null); clearProgramDeploymentProgress(); @@ -6662,11 +8243,12 @@ export default function Home() { const response = await apiFetch("program/deploy-source", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - source_dir: sourceDir, - build, - network: effectiveRpcRequest, - }), + body: JSON.stringify({ + source_dir: sourceDir, + build, + confirm_untrusted_build: build, + network: effectiveRpcRequest, + }), }); const data = (await response.json()) as ProgramDeploySourceResponse & { error?: string }; if (!response.ok) { @@ -6981,12 +8563,14 @@ export default function Home() { return; } - const response = await apiFetch("keys/create-keystore", { + const derivationPath = String(formData.derivationPath || "").trim(); + const response = await apiFetch("keys/create-mnemonic-keystore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password, name, + derivation_path: derivationPath || undefined, }), }); const data = await response.json(); @@ -7010,6 +8594,18 @@ export default function Home() { } await loadWallets(); toast.success(t("features.create-keystore.success")); + const mnemonic = String(data.mnemonic || "").trim(); + if (mnemonic) { + setPrivateKeyExportMode("simple"); + setPrivateKeySegmentCount(DEFAULT_PRIVATE_KEY_SEGMENTS); + setPrivateKeyQrRevealed(false); + setExportedPrivateKey(null); + setExportedMnemonic({ + walletName: name, + publicKey: data.public_key, + mnemonic, + }); + } } else { toast.error(saveData.error || t("features.create-keystore.saveFailed")); } @@ -7017,6 +8613,7 @@ export default function Home() { ...prev, publicKey: data.public_key, keystoreJson: data.keystore_json, + derivationPath: data.derivation_path || derivationPath, wallet_id: walletId, name, })); @@ -7026,6 +8623,73 @@ export default function Home() { break; } + case "import-mnemonic": { + const password = String(formData.password || ""); + const name = String(formData.name || "").trim(); + const mnemonic = String(formData.mnemonic || "").trim(); + const derivationPath = String(formData.derivationPath || "").trim(); + if (!name) { + toast.error(t("formUi.walletNameRequired")); + setLoading(false); + return; + } + if (!mnemonic || !password) { + toast.error(t("features.import-mnemonic.fillAllFields")); + setLoading(false); + return; + } + + const response = await apiFetch("keys/import-mnemonic-keystore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mnemonic, + password, + name, + derivation_path: derivationPath || undefined, + }), + }); + const data = await response.json(); + + if (response.ok) { + const saveResponse = await apiFetch("wallets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + keystore_json: data.keystore_json, + password, + name, + }), + }); + const saveData = await saveResponse.json(); + if (saveResponse.ok) { + toast.success(t("features.import-mnemonic.success")); + setCurrentWallet(saveData.wallet.id); + await loadWallets(); + setFormData((prev) => { + const next: FormState = { + ...prev, + publicKey: saveData.wallet.public_key, + wallet_id: saveData.wallet.id, + derivationPath: data.derivation_path || derivationPath, + name, + }; + delete next.password; + delete next.mnemonic; + delete next.keystoreJson; + delete next.secretKey; + delete next.private_key; + return next; + }); + } else { + toast.error(saveData.error || t("features.create-keystore.saveFailed")); + } + } else { + toast.error(data.error || t("features.import-mnemonic.error")); + } + break; + } + case "import-keystore": { const password = String(formData.password || ""); if (!formData.keystoreJson || !password) { @@ -8718,8 +10382,8 @@ export default function Home() { body: JSON.stringify({ hardware_fingerprint: formData.hardware_fingerprint, master_password: masterPassword, - account: formData.account || "sol-safekey", - issuer: formData.issuer || "Sol SafeKey", + account: formData.account || "fnzero-safe", + issuer: formData.issuer || "FnzeroSafe", }), }); const data = await response.json(); @@ -9231,6 +10895,12 @@ export default function Home() { ? t("features.settings.keystoreV2") : t("features.settings.keystoreLegacy")} + {biometricConfiguredFor(wallet) && ( + + + {t("features.biometric.touchId")} + + )}
- + {biometricConfiguredFor(wallet) && ( + + )} + {canUseBiometricWallet(wallet) && !biometricConfiguredFor(wallet) && ( + + )}

{emptyTitle}

{emptyDescription}

-
- - -
+ {!walletsLoading && ( +
+ + +
+ )} ); @@ -11722,61 +13410,284 @@ export default function Home() { case "dapp-store": return ( -
-
-
-
-

- {tf("features.dapp-store.currentWallet", "当前连接钱包")} -

- {effectiveWallet ? ( - <> -

- {effectiveWallet.name} -

- - {effectiveWallet.public_key} - - - ) : ( -

- {tf("features.dapp-store.noWallet", "请先选择一个钱包。")} -

- )} -
-
- {networkLabel(t, effectiveNetwork)} -
+
+
+ {dappTabs.map((tab, index) => { + const active = tab.id === activeDappTabId; + return ( + + ); + })} + +
+ + {activeDappTab.showAddressBar && ( +
+
+
+ + { + const value = event.target.value; + setDappTabs((tabs) => tabs.map((tab) => + tab.id === activeDappTab.id ? { ...tab, addressInput: value } : tab, + )); + }} + className="app-dapp-address-input h-10 min-w-0 flex-1 bg-transparent pl-1 pr-3 text-sm text-gray-100 outline-none placeholder:text-gray-600" + placeholder="https://" + spellCheck={false} + autoCapitalize="none" + /> +
+
-
+ )} + +
+ {activeDappTab.id === DAPP_HOME_TAB_ID ? ( +
+
+
+
+
+
+
+ + {tf("features.dapp-store.solanaOnly", "Solana Only")} +
+

+ {tf("features.dapp-store.homeTitle", "DApp Store")} +

+

+ {tf("features.dapp-store.homeSubtitle", "Trending Solana trading, DeFi, NFT, and staking apps.")} +

+
+
+
+

{tf("features.dapp-store.chain", "Chain")}

+

Solana

+
+
+

{tf("features.dapp-store.apps", "Apps")}

+

{SOLANA_DAPP_CATALOG.length}

+
+
+
+ +
+
+ + setDappSearch(event.target.value)} + className="app-dapp-search-input h-10 min-w-0 flex-1 bg-transparent text-sm text-gray-100 outline-none placeholder:text-gray-600" + placeholder={tf("features.dapp-store.searchPlaceholder", "Search Solana DApps")} + spellCheck={false} + /> +
+
+ {DAPP_CATEGORIES.map((category) => { + const active = category === dappCategory; + return ( + + ); + })} +
+
+
+ +
+
+
+

{tf("features.dapp-store.walletStatus", "Wallet")}

+

+ {effectiveWallet ? walletLabel(effectiveWallet) : tf("features.dapp-store.noWallet", "Select a wallet first.")} +

+
+ +
+
+ {dappStoreFeaturedDapps.slice(0, 2).map((dapp) => ( + + ))} +
+
+
+ +
+
+

{tf("features.dapp-store.trending", "Trending on Solana")}

+ {tf("features.dapp-store.filtered", "Non-Solana DApps filtered")} +
+
+ {dappStoreFeaturedDapps.map((dapp) => ( + + ))} +
+
-
- {DAPP_CATALOG.map((dapp) => ( -
-
-
-
-
-

{dapp.name}

-

{dapp.url}

+
+
+

{tf("features.dapp-store.allSolana", "All Solana DApps")}

+ + {dappStoreVisibleDapps.length} / {SOLANA_DAPP_CATALOG.length} +
- -
- + {dappStoreVisibleDapps.length > 0 ? ( +
+ {dappStoreVisibleDapps.map((dapp) => ( + + ))} +
+ ) : ( +
+ {tf("features.dapp-store.noResults", "No Solana DApps match this search.")} +
+ )} +
- ))} + ) : activeDappTab.webviewOpen ? ( +
+ {activeDappTab.loading && ( +
+ +
+ )} +
+ ) : ( +
+ {activeDappTab.title} +
+ )}
); @@ -11896,6 +13807,7 @@ export default function Home() { {renderActionGrid([ { id: "create-keystore", title: t("features.create-keystore.title"), icon: }, { id: "import-keystore", title: t("features.import-keystore.title"), icon: }, + { id: "import-mnemonic", title: t("features.import-mnemonic.title"), icon: }, ])} @@ -12193,6 +14105,17 @@ export default function Home() { placeholder={t("formUi.walletNamePlaceholder")} />
+
+ + handleFormChange("derivationPath", e.target.value)} + className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-white/20 text-white" + placeholder={t("features.create-keystore.derivationPathPlaceholder")} + /> +

{t("features.create-keystore.mnemonicHint")}

+
); + case "import-mnemonic": + return ( +
+
+ + handleFormChange("name", e.target.value)} + className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-white/20 text-white" + placeholder={t("formUi.walletNamePlaceholder")} + /> +
+
+ +