From dbc28205e8960bdf8a5a7b6a9613b3571d9bee2d Mon Sep 17 00:00:00 2001 From: ardocrat Date: Wed, 11 Sep 2024 17:01:05 +0300 Subject: [PATCH] desktop: parse file content from argument on launch, single app instance, wallets selection and opening modals refactoring --- Cargo.lock | 28 ++ Cargo.toml | 1 + linux/Grim.AppDir/grim.desktop | 3 +- src/gui/app.rs | 36 ++- src/gui/platform/android/mod.rs | 4 + src/gui/platform/desktop/mod.rs | 79 +++++- src/gui/platform/mod.rs | 2 + src/gui/views/modal.rs | 8 +- src/gui/views/network/setup/stratum.rs | 14 +- src/gui/views/wallets/content.rs | 251 +++++++----------- src/gui/views/wallets/modals/mod.rs | 5 +- src/gui/views/wallets/modals/open.rs | 121 +++++++++ src/gui/views/wallets/modals/wallets.rs | 117 +++++--- src/gui/views/wallets/wallet/content.rs | 35 +-- .../views/wallets/wallet/messages/content.rs | 14 +- src/gui/views/wallets/wallet/types.rs | 36 +++ src/main.rs | 151 ++++++++++- src/settings/settings.rs | 7 + src/wallet/list.rs | 3 +- 19 files changed, 672 insertions(+), 243 deletions(-) create mode 100644 src/gui/views/wallets/modals/open.rs diff --git a/Cargo.lock b/Cargo.lock index 0134201..7e53eb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2483,6 +2483,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + [[package]] name = "document-features" version = "0.2.8" @@ -3833,6 +3839,7 @@ dependencies = [ "hyper 0.14.29", "hyper-tls 0.5.0", "image 0.25.1", + "interprocess", "jni", "lazy_static", "local-ip-address", @@ -4976,6 +4983,21 @@ dependencies = [ "syn 2.0.66", ] +[[package]] +name = "interprocess" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f4e4a06d42fab3e85ab1b419ad32b09eab58b901d40c57935ff92db3287a13" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio 1.38.0", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "intl-memoizer" version = "0.5.2" @@ -7432,6 +7454,12 @@ dependencies = [ "rand_core 0.3.1", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.1.57" diff --git a/Cargo.toml b/Cargo.toml index e2ff09d..79edc3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,7 @@ eframe = { version = "0.28.1", features = ["wgpu", "glow"] } arboard = "3.2.0" rfd = "0.14.1" dark-light = "1.1.1" +interprocess = { version = "2.2.1", features = ["tokio"] } [target.'cfg(target_os = "android")'.dependencies] android_logger = "0.13.1" diff --git a/linux/Grim.AppDir/grim.desktop b/linux/Grim.AppDir/grim.desktop index 907cf68..781f500 100644 --- a/linux/Grim.AppDir/grim.desktop +++ b/linux/Grim.AppDir/grim.desktop @@ -3,4 +3,5 @@ Name=Grim Exec=grim Icon=grim Type=Application -Categories=Finance \ No newline at end of file +Categories=Finance +MimeType=application/x-slatepack;text/plain; \ No newline at end of file diff --git a/src/gui/app.rs b/src/gui/app.rs index 17fe021..dbbbcd3 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -32,25 +32,38 @@ lazy_static! { /// Implements ui entry point and contains platform-specific callbacks. pub struct App { /// Platform specific callbacks handler. - pub(crate) platform: Platform, - - /// Main ui content. + pub platform: Platform, + /// Main content. content: Content, - /// Last window resize direction. - resize_direction: Option + resize_direction: Option, + /// Flag to check if it's first draw. + first_draw: bool, } impl App { pub fn new(platform: Platform) -> Self { - Self { platform, content: Content::default(), resize_direction: None } + Self { + platform, + content: Content::default(), + resize_direction: None, + first_draw: true, + } } /// Draw application content. pub fn ui(&mut self, ctx: &Context) { + // Set Desktop platform context on first draw. + if self.first_draw { + if View::is_desktop() { + self.platform.set_context(ctx); + } + self.first_draw = false; + } + // Handle Esc keyboard key event and platform Back button key event. let back_pressed = BACK_BUTTON_PRESSED.load(Ordering::Relaxed); - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::Escape)) || back_pressed { + if back_pressed || ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::Escape)) { self.content.on_back(); if back_pressed { BACK_BUTTON_PRESSED.store(false, Ordering::Relaxed); @@ -59,8 +72,8 @@ impl App { ctx.request_repaint(); } - // Handle Close event (on desktop). - if ctx.input(|i| i.viewport().close_requested()) { + // Handle Close event on desktop. + if View::is_desktop() && ctx.input(|i| i.viewport().close_requested()) { if !self.content.exit_allowed { ctx.send_viewport_cmd(ViewportCommand::CancelClose); Content::show_exit_modal(); @@ -92,6 +105,11 @@ impl App { } self.content.ui(ui, &self.platform); } + + // Provide incoming data to wallets. + if let Some(data) = self.platform.consume_data() { + self.content.wallets.on_data(ui, Some(data), &self.platform); + } }); } diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index bb077f7..7fbe6ac 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -167,6 +167,10 @@ impl PlatformCallbacks for Android { } None } + + fn consume_data(&mut self) -> Option { + None + } } lazy_static! { diff --git a/src/gui/platform/desktop/mod.rs b/src/gui/platform/desktop/mod.rs index 5318bf4..9b4b210 100644 --- a/src/gui/platform/desktop/mod.rs +++ b/src/gui/platform/desktop/mod.rs @@ -13,12 +13,13 @@ // limitations under the License. use std::fs::File; -use std::io:: Write; -use lazy_static::lazy_static; -use parking_lot::RwLock; +use std::io::Write; +use std::thread; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::thread; +use parking_lot::RwLock; +use lazy_static::lazy_static; +use egui::{UserAttentionType, ViewportCommand}; use rfd::FileDialog; use crate::gui::platform::PlatformCallbacks; @@ -28,17 +29,16 @@ use crate::gui::platform::PlatformCallbacks; pub struct Desktop { /// Flag to check if camera stop is needed. stop_camera: Arc, -} - -impl Default for Desktop { - fn default() -> Self { - Self { - stop_camera: Arc::new(AtomicBool::new(false)), - } - } + /// Context to repaint content and handle viewport commands. + ctx: Arc>>, } impl PlatformCallbacks for Desktop { + fn set_context(&mut self, ctx: &egui::Context) { + let mut w_ctx = self.ctx.write(); + *w_ctx = Some(ctx.clone()); + } + fn show_keyboard(&self) {} fn hide_keyboard(&self) {} @@ -119,9 +119,61 @@ impl PlatformCallbacks for Desktop { fn picked_file(&self) -> Option { None } + + fn consume_data(&mut self) -> Option { + let has_data = { + let r_data = PASSED_DATA.read(); + r_data.is_some() + }; + if has_data { + // Reset window state. + let r_ctx = self.ctx.read(); + if r_ctx.is_some() { + let ctx = r_ctx.as_ref().unwrap(); + ctx.send_viewport_cmd( + ViewportCommand::RequestUserAttention(UserAttentionType::Reset) + ); + } + // Clear data. + let mut w_data = PASSED_DATA.write(); + let data = w_data.clone(); + *w_data = None; + return data; + } + None + } } impl Desktop { + /// Create new instance with provided extra data from app opening. + pub fn new(data: Option) -> Self { + let mut w_data = PASSED_DATA.write(); + *w_data = data; + Self { + stop_camera: Arc::new(AtomicBool::new(false)), + ctx: Arc::new(RwLock::new(None)), + } + } + + /// Handle data passed to application. + pub fn on_data(&self, data: String) { + let mut w_data = PASSED_DATA.write(); + *w_data = Some(data); + + // Bring focus on window. + let r_ctx = self.ctx.read(); + if r_ctx.is_some() { + let ctx = r_ctx.as_ref().unwrap(); + ctx.send_viewport_cmd(ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd( + ViewportCommand::RequestUserAttention(UserAttentionType::Informational) + ); + ctx.send_viewport_cmd(ViewportCommand::Focus); + ctx.request_repaint(); + } + } + #[allow(dead_code)] #[cfg(target_os = "windows")] fn start_camera_capture(stop_camera: Arc) { @@ -205,4 +257,7 @@ impl Desktop { lazy_static! { /// Last captured image from started camera. static ref LAST_CAMERA_IMAGE: Arc, u32)>>> = Arc::new(RwLock::new(None)); + + /// Data passed from deeplink or opened file. + static ref PASSED_DATA: Arc>> = Arc::new(RwLock::new(None)); } diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index e5bc3be..06f6a3e 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -22,6 +22,7 @@ pub mod platform; pub mod platform; pub trait PlatformCallbacks { + fn set_context(&mut self, ctx: &egui::Context); fn show_keyboard(&self); fn hide_keyboard(&self); fn copy_string_to_buffer(&self, data: String); @@ -34,4 +35,5 @@ pub trait PlatformCallbacks { fn share_data(&self, name: String, data: Vec) -> Result<(), std::io::Error>; fn pick_file(&self) -> Option; fn picked_file(&self) -> Option; + fn consume_data(&mut self) -> Option; } \ No newline at end of file diff --git a/src/gui/views/modal.rs b/src/gui/views/modal.rs index 6cff50e..7b3359a 100644 --- a/src/gui/views/modal.rs +++ b/src/gui/views/modal.rs @@ -35,7 +35,7 @@ pub struct Modal { /// Identifier for modal. pub(crate) id: &'static str, /// Position on the screen. - position: ModalPosition, + pub position: ModalPosition, /// To check if it can be closed. closeable: Arc, /// Title text @@ -64,6 +64,12 @@ impl Modal { self } + /// Change [`Modal`] position on the screen. + pub fn change_position(position: ModalPosition) { + let mut w_state = MODAL_STATE.write(); + w_state.modal.as_mut().unwrap().position = position; + } + /// Mark [`Modal`] closed. pub fn close(&self) { let mut w_nav = MODAL_STATE.write(); diff --git a/src/gui/views/network/setup/stratum.rs b/src/gui/views/network/setup/stratum.rs index 046c412..3d85aca 100644 --- a/src/gui/views/network/setup/stratum.rs +++ b/src/gui/views/network/setup/stratum.rs @@ -83,7 +83,7 @@ impl Default for StratumSetup { Self { wallets: WalletList::default(), - wallets_modal: WalletsModal::new(wallet_id), + wallets_modal: WalletsModal::new(wallet_id, None, false), available_ips: NodeConfig::get_ip_addrs(), stratum_port_edit: port, stratum_port_available_edit: is_port_available, @@ -111,10 +111,12 @@ impl ModalContainer for StratumSetup { modal: &Modal, cb: &dyn PlatformCallbacks) { match modal.id { - WALLET_SELECTION_MODAL => self.wallets_modal.ui(ui, modal, &self.wallets, |id| { - NodeConfig::save_stratum_wallet_id(id); - self.wallet_name = WalletConfig::name_by_id(id); - }), + WALLET_SELECTION_MODAL => { + self.wallets_modal.ui(ui, modal, &mut self.wallets, cb, |id, _| { + NodeConfig::save_stratum_wallet_id(id); + self.wallet_name = WalletConfig::name_by_id(id); + }) + }, STRATUM_PORT_MODAL => self.port_modal(ui, modal, cb), ATTEMPT_TIME_MODAL => self.attempt_modal(ui, modal, cb), MIN_SHARE_DIFF_MODAL => self.min_diff_modal(ui, modal, cb), @@ -240,7 +242,7 @@ impl StratumSetup { /// Show wallet selection [`Modal`]. fn show_wallets_modal(&mut self) { - self.wallets_modal = WalletsModal::new(NodeConfig::get_stratum_wallet_id()); + self.wallets_modal = WalletsModal::new(NodeConfig::get_stratum_wallet_id(), None, false); // Show modal. Modal::new(WALLET_SELECTION_MODAL) .position(ModalPosition::Center) diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index 61b34c0..45ca51d 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -18,13 +18,14 @@ use egui::scroll_area::ScrollBarVisibility; use crate::AppConfig; use crate::gui::Colors; -use crate::gui::icons::{ARROW_LEFT, CARET_RIGHT, COMPUTER_TOWER, FOLDER_LOCK, FOLDER_OPEN, GEAR, GLOBE, GLOBE_SIMPLE, LOCK_KEY, PLUS, SIDEBAR_SIMPLE, SPINNER, SUITCASE, WARNING_CIRCLE}; +use crate::gui::icons::{ARROW_LEFT, CARET_RIGHT, COMPUTER_TOWER, FOLDER_OPEN, GEAR, GLOBE, GLOBE_SIMPLE, LOCK_KEY, PLUS, SIDEBAR_SIMPLE, SUITCASE}; use crate::gui::platform::PlatformCallbacks; use crate::gui::views::{Modal, Content, TitlePanel, View}; -use crate::gui::views::types::{ModalContainer, ModalPosition, TextEditOptions, TitleContentType, TitleType}; +use crate::gui::views::types::{ModalContainer, ModalPosition, TitleContentType, TitleType}; use crate::gui::views::wallets::creation::WalletCreation; -use crate::gui::views::wallets::modals::WalletConnectionModal; +use crate::gui::views::wallets::modals::{OpenWalletModal, WalletConnectionModal, WalletsModal}; use crate::gui::views::wallets::types::WalletTabType; +use crate::gui::views::wallets::wallet::types::status_text; use crate::gui::views::wallets::WalletContent; use crate::wallet::{Wallet, WalletList}; @@ -33,10 +34,11 @@ pub struct WalletsContent { /// List of wallets. wallets: WalletList, - /// Password to open wallet for [`Modal`]. - pass_edit: String, - /// Flag to check if wrong password was entered at [`Modal`]. - wrong_pass: bool, + /// Wallet selection [`Modal`] content. + wallet_selection_content: Option, + + /// Wallet opening [`Modal`] content. + open_wallet_content: Option, /// Wallet connection selection content. conn_modal_content: Option, @@ -54,24 +56,29 @@ pub struct WalletsContent { } /// Identifier for connection selection [`Modal`]. -const CONNECTION_SELECTION_MODAL: &'static str = "wallets_connection_selection_modal"; +const CONNECTION_SELECTION_MODAL: &'static str = "wallets_connection_selection"; + /// Identifier for wallet opening [`Modal`]. -const OPEN_WALLET_MODAL: &'static str = "open_wallet_modal"; +const OPEN_WALLET_MODAL: &'static str = "wallets_open_wallet"; + +/// Identifier for wallet opening [`Modal`]. +const SELECT_WALLET_MODAL: &'static str = "wallets_select_wallet"; impl Default for WalletsContent { fn default() -> Self { Self { wallets: WalletList::default(), - pass_edit: "".to_string(), - wrong_pass: false, + wallet_selection_content: None, + open_wallet_content: None, conn_modal_content: None, - wallet_content: WalletContent::default(), + wallet_content: WalletContent::new(None), creation_content: WalletCreation::default(), show_wallets_at_dual_panel: AppConfig::show_wallets_at_dual_panel(), modal_ids: vec![ OPEN_WALLET_MODAL, WalletCreation::NAME_PASS_MODAL, CONNECTION_SELECTION_MODAL, + SELECT_WALLET_MODAL ] } } @@ -87,13 +94,21 @@ impl ModalContainer for WalletsContent { modal: &Modal, cb: &dyn PlatformCallbacks) { match modal.id { - OPEN_WALLET_MODAL => self.open_wallet_modal_ui(ui, modal, cb), + OPEN_WALLET_MODAL => { + if let Some(content) = self.open_wallet_content.as_mut() { + content.ui(ui, modal, &mut self.wallets, cb, |data| { + // Setup wallet content. + self.wallet_content = WalletContent::new(data); + }); + } + }, WalletCreation::NAME_PASS_MODAL => { self.creation_content.name_pass_modal_ui(ui, modal, cb) }, CONNECTION_SELECTION_MODAL => { if let Some(content) = self.conn_modal_content.as_mut() { content.ui(ui, modal, cb, |id| { + // Update wallet connection on select. let list = self.wallets.list(); for w in list { if self.wallets.selected_id == Some(w.get_config().id) { @@ -103,12 +118,20 @@ impl ModalContainer for WalletsContent { }); } } + SELECT_WALLET_MODAL => { + if let Some(content) = self.wallet_selection_content.as_mut() { + content.ui(ui, modal, &mut self.wallets, cb, |_, data| { + self.wallet_content = WalletContent::new(data); + }); + } + } _ => {} } } } impl WalletsContent { + /// Draw wallets content. pub fn ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { // Draw modal content for current ui container. self.current_modal_ui(ui, cb); @@ -159,7 +182,7 @@ impl WalletsContent { // Add created wallet to list. self.wallets.add(wallet); // Reset wallet content. - self.wallet_content = WalletContent::default(); + self.wallet_content = WalletContent::new(None); }); } else { let selected_id = self.wallets.selected_id.clone(); @@ -254,6 +277,56 @@ impl WalletsContent { self.creation_content.can_go_back() } + /// Handle data from deeplink or opened file. + pub fn on_data(&mut self, ui: &mut egui::Ui, data: Option, cb: &dyn PlatformCallbacks) { + let wallets_size = self.wallets.list().len(); + if wallets_size == 0 { + return; + } + // Close network panel on single panel mode. + if !Content::is_dual_panel_mode(ui) && Content::is_network_panel_open() { + Content::toggle_network_panel(); + } + // Pass data to opened selected wallet or show wallets selection. + if self.wallets.is_selected_open() { + if wallets_size == 1 { + self.wallet_content = WalletContent::new(data); + } else { + self.show_wallet_selection_modal(data); + } + } else { + if wallets_size == 1 { + self.show_opening_modal(self.wallets.list()[0].get_config().id, data, cb); + } else { + self.show_wallet_selection_modal(data); + } + } + } + + fn show_wallet_selection_modal(&mut self, data: Option) { + self.wallet_selection_content = Some(WalletsModal::new(None, data, true)); + // Show wallet selection modal. + Modal::new(SELECT_WALLET_MODAL) + .position(ModalPosition::Center) + .title(t!("network_settings.choose_wallet")) + .show(); + } + + /// Handle Back key event returning `false` when event was handled. + pub fn on_back(&mut self) -> bool { + let can_go_back = self.creation_content.can_go_back(); + if can_go_back { + self.creation_content.back(); + return false + } else { + if self.wallets.is_selected_open() { + self.wallets.select(None); + return false + } + } + true + } + /// Draw [`TitlePanel`] content. fn title_ui(&mut self, ui: &mut egui::Ui, @@ -383,8 +456,7 @@ impl WalletsContent { // Check if wallet reopen is needed. if !wallet.is_open() && wallet.reopen_needed() { wallet.set_reopen(false); - self.wallets.select(Some(wallet.get_config().id)); - self.show_open_wallet_modal(cb); + self.show_opening_modal(wallet.get_config().id, None, cb); } // Draw wallet list item. self.wallet_item_ui(ui, wallet, cb); @@ -420,8 +492,7 @@ impl WalletsContent { if !wallet.is_open() { // Show button to open closed wallet. View::item_button(ui, View::item_rounding(0, 1, true), FOLDER_OPEN, None, || { - self.wallets.select(Some(id)); - self.show_open_wallet_modal(cb); + self.show_opening_modal(id, None, cb); }); // Show button to select connection if not syncing. if !wallet.syncing() { @@ -435,7 +506,7 @@ impl WalletsContent { // Show button to select opened wallet. View::item_button(ui, View::item_rounding(0, 1, true), CARET_RIGHT, None, || { self.wallets.select(Some(id)); - self.wallet_content = WalletContent::default(); + self.wallet_content = WalletContent::new(None); }); } // Show button to close opened wallet. @@ -455,7 +526,7 @@ impl WalletsContent { ui.add_space(6.0); ui.vertical(|ui| { ui.add_space(3.0); - // Setup wallet name text. + // Show wallet name text. let name_color = if is_selected { Colors::white_or_black(true) } else { @@ -466,42 +537,11 @@ impl WalletsContent { View::ellipsize_text(ui, config.name, 18.0, name_color); }); - // Setup wallet status text. - let status_text = if wallet.is_open() { - if wallet.sync_error() { - format!("{} {}", WARNING_CIRCLE, t!("error")) - } else if wallet.is_closing() { - format!("{} {}", SPINNER, t!("wallets.closing")) - } else if wallet.is_repairing() { - let repair_progress = wallet.repairing_progress(); - if repair_progress == 0 { - format!("{} {}", SPINNER, t!("wallets.checking")) - } else { - format!("{} {}: {}%", - SPINNER, - t!("wallets.checking"), - repair_progress) - } - } else if wallet.syncing() { - let info_progress = wallet.info_sync_progress(); - if info_progress == 100 || info_progress == 0 { - format!("{} {}", SPINNER, t!("wallets.loading")) - } else { - format!("{} {}: {}%", - SPINNER, - t!("wallets.loading"), - info_progress) - } - } else { - format!("{} {}", FOLDER_OPEN, t!("wallets.unlocked")) - } - } else { - format!("{} {}", FOLDER_LOCK, t!("wallets.locked")) - }; - View::ellipsize_text(ui, status_text, 15.0, Colors::text(false)); + // Show wallet status text. + View::ellipsize_text(ui, status_text(wallet), 15.0, Colors::text(false)); ui.add_space(1.0); - // Setup wallet connection text. + // Show wallet connection text. let conn_text = if let Some(conn) = wallet.get_current_ext_conn() { format!("{} {}", GLOBE_SIMPLE, conn.url) } else { @@ -525,11 +565,10 @@ impl WalletsContent { .show(); } - /// Show [`Modal`] to open selected wallet. - fn show_open_wallet_modal(&mut self, cb: &dyn PlatformCallbacks) { - // Reset modal values. - self.pass_edit = String::from(""); - self.wrong_pass = false; + /// Show [`Modal`] to select and open wallet. + fn show_opening_modal(&mut self, id: i64, data: Option, cb: &dyn PlatformCallbacks) { + self.wallets.select(Some(id)); + self.open_wallet_content = Some(OpenWalletModal::new(data)); // Show modal. Modal::new(OPEN_WALLET_MODAL) .position(ModalPosition::CenterTop) @@ -537,100 +576,6 @@ impl WalletsContent { .show(); cb.show_keyboard(); } - - /// Draw wallet opening [`Modal`] content. - fn open_wallet_modal_ui(&mut self, - ui: &mut egui::Ui, - modal: &Modal, - cb: &dyn PlatformCallbacks) { - ui.add_space(6.0); - ui.vertical_centered(|ui| { - ui.label(RichText::new(t!("wallets.pass")) - .size(17.0) - .color(Colors::gray())); - ui.add_space(8.0); - - // Show password input. - let mut pass_edit_opts = TextEditOptions::new(Id::from(modal.id)).password(); - View::text_edit(ui, cb, &mut self.pass_edit, &mut pass_edit_opts); - - // Show information when password is empty. - if self.pass_edit.is_empty() { - self.wrong_pass = false; - ui.add_space(10.0); - ui.label(RichText::new(t!("wallets.pass_empty")) - .size(17.0) - .color(Colors::inactive_text())); - } else if self.wrong_pass { - ui.add_space(10.0); - ui.label(RichText::new(t!("wallets.wrong_pass")) - .size(17.0) - .color(Colors::red())); - } - ui.add_space(12.0); - }); - - // Show modal buttons. - ui.scope(|ui| { - // Setup spacing between buttons. - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - - ui.columns(2, |columns| { - columns[0].vertical_centered_justified(|ui| { - View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { - // Close modal. - cb.hide_keyboard(); - modal.close(); - }); - }); - columns[1].vertical_centered_justified(|ui| { - // Callback for button to continue. - let mut on_continue = || { - if self.pass_edit.is_empty() { - return; - } - match self.wallets.open_selected(&self.pass_edit) { - Ok(_) => { - // Clear values. - self.pass_edit = "".to_string(); - self.wrong_pass = false; - // Close modal. - cb.hide_keyboard(); - modal.close(); - // Reset wallet content. - self.wallet_content = WalletContent::default(); - } - Err(_) => self.wrong_pass = true - } - }; - - // Continue on Enter key press. - View::on_enter_key(ui, || { - (on_continue)(); - }); - - View::button(ui, t!("continue"), Colors::white_or_black(false), on_continue); - }); - }); - ui.add_space(6.0); - }); - } - - /// Handle Back key event. - /// Return `false` when event was handled. - pub fn on_back(&mut self) -> bool { - let can_go_back = self.creation_content.can_go_back(); - if can_go_back { - self.creation_content.back(); - return false - } else { - if self.wallets.is_selected_open() { - self.wallets.select(None); - return false - } - } - true - } } /// Check if it's possible to show [`WalletsContent`] and [`WalletContent`] panels at same time. diff --git a/src/gui/views/wallets/modals/mod.rs b/src/gui/views/wallets/modals/mod.rs index c4f566c..bdb7bb6 100644 --- a/src/gui/views/wallets/modals/mod.rs +++ b/src/gui/views/wallets/modals/mod.rs @@ -16,4 +16,7 @@ mod conn; pub use conn::*; mod wallets; -pub use wallets::*; \ No newline at end of file +pub use wallets::*; + +mod open; +pub use open::*; \ No newline at end of file diff --git a/src/gui/views/wallets/modals/open.rs b/src/gui/views/wallets/modals/open.rs new file mode 100644 index 0000000..c585d99 --- /dev/null +++ b/src/gui/views/wallets/modals/open.rs @@ -0,0 +1,121 @@ +// Copyright 2024 The Grim Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use egui::{Id, RichText}; + +use crate::gui::Colors; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::{Modal, View}; +use crate::gui::views::types::TextEditOptions; +use crate::wallet::WalletList; + +/// Wallet opening [`Modal`] content. +pub struct OpenWalletModal { + /// Password to open wallet. + pass_edit: String, + /// Flag to check if wrong password was entered. + wrong_pass: bool, + + /// Optional data to pass after wallet opening. + data: Option, +} + +impl OpenWalletModal { + /// Create new content instance. + pub fn new(data: Option) -> Self { + Self { + pass_edit: "".to_string(), + wrong_pass: false, + data, + } + } + /// Draw [`Modal`] content. + pub fn ui(&mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallets: &mut WalletList, + cb: &dyn PlatformCallbacks, + mut on_continue: impl FnMut(Option)) { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label(RichText::new(t!("wallets.pass")) + .size(17.0) + .color(Colors::gray())); + ui.add_space(8.0); + + // Show password input. + let mut pass_edit_opts = TextEditOptions::new(Id::from(modal.id)).password(); + View::text_edit(ui, cb, &mut self.pass_edit, &mut pass_edit_opts); + + // Show information when password is empty. + if self.pass_edit.is_empty() { + self.wrong_pass = false; + ui.add_space(10.0); + ui.label(RichText::new(t!("wallets.pass_empty")) + .size(17.0) + .color(Colors::inactive_text())); + } else if self.wrong_pass { + ui.add_space(10.0); + ui.label(RichText::new(t!("wallets.wrong_pass")) + .size(17.0) + .color(Colors::red())); + } + ui.add_space(12.0); + }); + + // Show modal buttons. + ui.scope(|ui| { + // Setup spacing between buttons. + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { + // Close modal. + cb.hide_keyboard(); + modal.close(); + }); + }); + columns[1].vertical_centered_justified(|ui| { + // Callback for button to continue. + let mut on_continue = || { + if self.pass_edit.is_empty() { + return; + } + match wallets.open_selected(&self.pass_edit) { + Ok(_) => { + // Clear values. + self.pass_edit = "".to_string(); + self.wrong_pass = false; + // Close modal. + cb.hide_keyboard(); + modal.close(); + on_continue(self.data.clone()); + } + Err(_) => self.wrong_pass = true + } + }; + + // Continue on Enter key press. + View::on_enter_key(ui, || { + (on_continue)(); + }); + + View::button(ui, t!("continue"), Colors::white_or_black(false), on_continue); + }); + }); + ui.add_space(6.0); + }); + } +} \ No newline at end of file diff --git a/src/gui/views/wallets/modals/wallets.rs b/src/gui/views/wallets/modals/wallets.rs index b2bb8d5..b8b846b 100644 --- a/src/gui/views/wallets/modals/wallets.rs +++ b/src/gui/views/wallets/modals/wallets.rs @@ -16,29 +16,53 @@ use egui::scroll_area::ScrollBarVisibility; use egui::{Align, Layout, RichText, ScrollArea}; use crate::gui::Colors; -use crate::gui::icons::{CHECK, CHECK_FAT, COMPUTER_TOWER, GLOBE_SIMPLE, PLUGS_CONNECTED}; +use crate::gui::icons::{CHECK, CHECK_FAT, COMPUTER_TOWER, FOLDER_OPEN, GLOBE_SIMPLE, PLUGS_CONNECTED}; +use crate::gui::platform::PlatformCallbacks; use crate::gui::views::{Modal, View}; +use crate::gui::views::types::ModalPosition; +use crate::gui::views::wallets::modals::OpenWalletModal; +use crate::gui::views::wallets::wallet::types::status_text; use crate::wallet::{Wallet, WalletList}; /// Wallet list [`Modal`] content pub struct WalletsModal { /// Selected wallet id. - selected: Option + selected: Option, + + /// Optional data to pass after wallet selection. + data: Option, + + /// Flag to check if wallet can be opened from the list. + can_open: bool, + /// Wallet opening content. + open_wallet_content: Option, } impl WalletsModal { - pub fn new(selected: Option) -> Self { - Self { - selected, - } + /// Create new content instance. + pub fn new(selected: Option, data: Option, can_open: bool) -> Self { + Self { selected, data, can_open, open_wallet_content: None } } - /// Draw [`Modal`] content. + /// Draw content. pub fn ui(&mut self, ui: &mut egui::Ui, modal: &Modal, - wallets: &WalletList, - mut on_select: impl FnMut(i64)) { + wallets: &mut WalletList, + cb: &dyn PlatformCallbacks, + mut on_select: impl FnMut(i64, Option)) { + // Draw wallet opening content if requested. + if let Some(open_content) = self.open_wallet_content.as_mut() { + open_content.ui(ui, modal, wallets, cb, |data| { + modal.close(); + if let Some(id) = self.selected { + on_select(id, data); + } + self.data = None; + }); + return; + } + ui.add_space(4.0); ScrollArea::vertical() .max_height(373.0) @@ -48,10 +72,12 @@ impl WalletsModal { .show(ui, |ui| { ui.add_space(2.0); ui.vertical_centered(|ui| { - for wallet in wallets.list() { + let data = self.data.clone(); + for wallet in wallets.clone().list() { // Draw wallet list item. - self.wallet_item_ui(ui, wallet, modal, |id| { - on_select(id); + self.wallet_item_ui(ui, wallet, wallets, |id| { + modal.close(); + on_select(id, data.clone()); }); ui.add_space(5.0); } @@ -65,18 +91,19 @@ impl WalletsModal { // Show button to close modal. ui.vertical_centered_justified(|ui| { View::button(ui, t!("modal.cancel"), Colors::white_or_black(false), || { + self.data = None; modal.close(); }); }); ui.add_space(6.0); } - /// Draw wallet list item. + /// Draw wallet list item with provided callback on select. fn wallet_item_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, - modal: &Modal, - mut on_select: impl FnMut(i64)) { + wallets: &mut WalletList, + mut select: impl FnMut(i64)) { let config = wallet.get_config(); let id = config.id; @@ -87,16 +114,34 @@ impl WalletsModal { ui.painter().rect(rect, rounding, Colors::fill(), View::hover_stroke()); ui.allocate_ui_with_layout(rect.size(), Layout::right_to_left(Align::Center), |ui| { - // Draw button to select wallet. - let current = self.selected.unwrap_or(0) == id; - if current { - ui.add_space(12.0); - ui.label(RichText::new(CHECK_FAT).size(20.0).color(Colors::green())); - } else { - View::item_button(ui, View::item_rounding(0, 1, true), CHECK, None, || { - on_select(id); - modal.close(); + if self.can_open { + // Show button to select or open closed wallet. + let icon = if wallet.is_open() { + CHECK + } else { + FOLDER_OPEN + }; + View::item_button(ui, View::item_rounding(0, 1, true), icon, None, || { + wallets.select(Some(id)); + if wallet.is_open() { + select(id); + } else { + self.selected = wallets.selected_id; + Modal::change_position(ModalPosition::CenterTop); + self.open_wallet_content = Some(OpenWalletModal::new(self.data.clone())); + } }); + } else { + // Draw button to select wallet. + let current = self.selected.unwrap_or(0) == id; + if current { + ui.add_space(12.0); + ui.label(RichText::new(CHECK_FAT).size(20.0).color(Colors::green())); + } else { + View::item_button(ui, View::item_rounding(0, 1, true), CHECK, None, || { + select(id); + }); + } } let layout_size = ui.available_size(); @@ -104,13 +149,13 @@ impl WalletsModal { ui.add_space(6.0); ui.vertical(|ui| { ui.add_space(3.0); - // Setup wallet name text. + // Show wallet name text. ui.with_layout(Layout::left_to_right(Align::Min), |ui| { ui.add_space(1.0); View::ellipsize_text(ui, config.name, 18.0, Colors::title(false)); }); - // Setup wallet connection text. + // Show wallet connection text. let conn = if let Some(conn) = wallet.get_current_ext_conn() { format!("{} {}", GLOBE_SIMPLE, conn.url) } else { @@ -119,14 +164,20 @@ impl WalletsModal { View::ellipsize_text(ui, conn, 15.0, Colors::text(false)); ui.add_space(1.0); - // Setup wallet API text. - let address = if let Some(port) = config.api_port { - format!("127.0.0.1:{}", port) + // Show wallet API text or open status. + if self.can_open { + ui.label(RichText::new(status_text(wallet)) + .size(15.0) + .color(Colors::gray())); } else { - "-".to_string() - }; - let api_text = format!("{} {}", PLUGS_CONNECTED, address); - ui.label(RichText::new(api_text).size(15.0).color(Colors::gray())); + let address = if let Some(port) = config.api_port { + format!("127.0.0.1:{}", port) + } else { + "-".to_string() + }; + let api_text = format!("{} {}", PLUGS_CONNECTED, address); + ui.label(RichText::new(api_text).size(15.0).color(Colors::gray())); + } ui.add_space(3.0); }); }); diff --git a/src/gui/views/wallets/wallet/content.rs b/src/gui/views/wallets/wallet/content.rs index 3cd5061..8888931 100644 --- a/src/gui/views/wallets/wallet/content.rs +++ b/src/gui/views/wallets/wallet/content.rs @@ -35,7 +35,6 @@ use crate::wallet::types::{WalletAccount, WalletData}; pub struct WalletContent { /// List of wallet accounts for [`Modal`]. accounts: Vec, - /// Flag to check if account is creating. account_creating: bool, /// Account label [`Modal`] value. @@ -49,21 +48,7 @@ pub struct WalletContent { qr_scan_result: Option, /// Current tab content to show. - pub current_tab: Box -} - -impl Default for WalletContent { - fn default() -> Self { - Self { - accounts: vec![], - account_creating: false, - account_label_edit: "".to_string(), - account_creation_error: false, - camera_content: CameraContent::default(), - qr_scan_result: None, - current_tab: Box::new(WalletTransactions::default()) - } - } + pub current_tab: Box, } /// Identifier for account list [`Modal`]. @@ -73,6 +58,24 @@ const ACCOUNT_LIST_MODAL: &'static str = "account_list_modal"; const QR_CODE_SCAN_MODAL: &'static str = "qr_code_scan_modal"; impl WalletContent { + /// Create new instance with optional data. + pub fn new(data: Option) -> Self { + let mut content = Self { + accounts: vec![], + account_creating: false, + account_label_edit: "".to_string(), + account_creation_error: false, + camera_content: CameraContent::default(), + qr_scan_result: None, + current_tab: Box::new(WalletTransactions::default()), + }; + // Provide data to messages. + if data.is_some() { + content.current_tab = Box::new(WalletMessages::new(data)); + } + content + } + /// Draw wallet content. pub fn ui(&mut self, ui: &mut egui::Ui, diff --git a/src/gui/views/wallets/wallet/messages/content.rs b/src/gui/views/wallets/wallet/messages/content.rs index b0214e5..5fded1b 100644 --- a/src/gui/views/wallets/wallet/messages/content.rs +++ b/src/gui/views/wallets/wallet/messages/content.rs @@ -33,13 +33,16 @@ use crate::wallet::Wallet; /// Slatepack messages interaction tab content. pub struct WalletMessages { + /// Flag to check if it's first content draw. + first_draw: bool, + /// Slatepacks message input text. message_edit: String, /// Flag to check if message request is loading. message_loading: bool, /// Error on finalization, parse or response creation. message_error: String, - /// Parsed message result with finalization flag and transaction. + /// Parsed message result. message_result: Arc)>>>, /// Wallet transaction [`Modal`] content. @@ -111,6 +114,7 @@ impl WalletMessages { /// Create new content instance, put message into input if provided. pub fn new(message: Option) -> Self { Self { + first_draw: true, message_edit: message.unwrap_or("".to_string()), message_loading: false, message_error: "".to_string(), @@ -128,6 +132,14 @@ impl WalletMessages { ui: &mut egui::Ui, wallet: &mut Wallet, cb: &dyn PlatformCallbacks) { + if self.first_draw { + // Parse provided message on first draw. + if !self.message_edit.is_empty() { + self.parse_message(wallet); + } + self.first_draw = false; + } + ui.add_space(3.0); // Show creation of request to send or receive funds. diff --git a/src/gui/views/wallets/wallet/types.rs b/src/gui/views/wallets/wallet/types.rs index e9f5a4d..750fc02 100644 --- a/src/gui/views/wallets/wallet/types.rs +++ b/src/gui/views/wallets/wallet/types.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use crate::gui::icons::{FOLDER_LOCK, FOLDER_OPEN, SPINNER, WARNING_CIRCLE}; use crate::gui::platform::PlatformCallbacks; use crate::wallet::Wallet; @@ -48,4 +49,39 @@ impl WalletTabType { WalletTabType::Settings => t!("wallets.settings") } } +} + +/// Get wallet status text. +pub fn status_text(wallet: &Wallet) -> String { + if wallet.is_open() { + if wallet.sync_error() { + format!("{} {}", WARNING_CIRCLE, t!("error")) + } else if wallet.is_closing() { + format!("{} {}", SPINNER, t!("wallets.closing")) + } else if wallet.is_repairing() { + let repair_progress = wallet.repairing_progress(); + if repair_progress == 0 { + format!("{} {}", SPINNER, t!("wallets.checking")) + } else { + format!("{} {}: {}%", + SPINNER, + t!("wallets.checking"), + repair_progress) + } + } else if wallet.syncing() { + let info_progress = wallet.info_sync_progress(); + if info_progress == 100 || info_progress == 0 { + format!("{} {}", SPINNER, t!("wallets.loading")) + } else { + format!("{} {}: {}%", + SPINNER, + t!("wallets.loading"), + info_progress) + } + } else { + format!("{} {}", FOLDER_OPEN, t!("wallets.unlocked")) + } + } else { + format!("{} {}", FOLDER_LOCK, t!("wallets.locked")) + } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 0ff285d..9b3a2e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,23 @@ fn real_main() { .parse_default_env() .init(); + // Handle file path argument passing. + let args: Vec<_> = std::env::args().collect(); + let mut data = None; + if args.len() > 1 { + let path = std::path::PathBuf::from(&args[1]); + let content = match std::fs::read_to_string(path) { + Ok(s) => Some(s), + Err(_) => None + }; + data = content + } + + // Check if another app instance already running. + if is_app_running(data.clone()) { + return; + } + // Setup callback on panic crash. std::panic::set_hook(Box::new(|info| { let backtrace = backtrace::Backtrace::new(); @@ -50,22 +67,61 @@ fn real_main() { // Start GUI. match std::panic::catch_unwind(|| { - start_desktop_gui(); + start_desktop_gui(data); }) { Ok(_) => {} Err(e) => println!("{:?}", e) } } -/// Start GUI with Desktop related setup. +/// Check if application is already running to pass extra data. #[allow(dead_code)] #[cfg(not(target_os = "android"))] -fn start_desktop_gui() { +fn is_app_running(data: Option) -> bool { + use tor_rtcompat::BlockOn; + let runtime = tor_rtcompat::tokio::TokioNativeTlsRuntime::create().unwrap(); + let res: Result<(), Box> = runtime + .block_on(async { + use interprocess::local_socket::{ + tokio::{prelude::*, Stream}, + GenericFilePath + }; + use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + try_join, + }; + + let socket_path = grim::Settings::socket_path(); + let name = socket_path.to_fs_name::()?; + // Connect to running application socket. + let conn = Stream::connect(name).await?; + + let (rec, mut sen) = conn.split(); + let mut rec = BufReader::new(rec); + let data = data.unwrap_or("".to_string()); + let mut buffer = String::with_capacity(data.len()); + + // Send extra data to socket. + let send = sen.write_all(data.as_bytes()); + let recv = rec.read_line(&mut buffer); + try_join!(send, recv)?; + + drop((rec, sen)); + Ok(()) + }); + return match res { + Ok(_) => true, + Err(_) => false + } +} + +/// Start GUI with Desktop related setup passing extra data from opening. +#[allow(dead_code)] +#[cfg(not(target_os = "android"))] +fn start_desktop_gui(data: Option) { use grim::AppConfig; use dark_light::Mode; - let platform = grim::gui::platform::Desktop::default(); - // Setup system theme if not set. if let None = AppConfig::dark_theme() { let dark = match dark_light::detect() { @@ -76,12 +132,11 @@ fn start_desktop_gui() { AppConfig::set_dark_theme(dark); } - // Setup window size. let (width, height) = AppConfig::window_size(); - let mut viewport = egui::ViewportBuilder::default() .with_min_inner_size([AppConfig::MIN_WIDTH, AppConfig::MIN_HEIGHT]) .with_inner_size([width, height]); + // Setup an icon. if let Ok(icon) = eframe::icon_data::from_png_bytes(include_bytes!("../img/icon.png")) { viewport = viewport.with_icon(std::sync::Arc::new(icon)); @@ -93,6 +148,7 @@ fn start_desktop_gui() { // Setup window decorations. let is_mac = egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac; viewport = viewport + .with_window_level(egui::WindowLevel::Normal) .with_fullsize_content_view(true) .with_title_shown(false) .with_titlebar_buttons_shown(false) @@ -112,8 +168,18 @@ fn start_desktop_gui() { eframe::Renderer::Wgpu }; + let mut platform = grim::gui::platform::Desktop::new(data); + + // Start app socket at separate thread. + let socket_pl = platform.clone(); + platform = socket_pl.clone(); + std::thread::spawn(move || { + start_app_socket(socket_pl); + }); + // Start GUI. - match grim::start(options.clone(), grim::app_creator(grim::gui::App::new(platform.clone()))) { + let app = grim::gui::App::new(platform.clone()); + match grim::start(options.clone(), grim::app_creator(app)) { Ok(_) => {} Err(e) => { if win { @@ -121,7 +187,9 @@ fn start_desktop_gui() { } // Start with another renderer on error. options.renderer = eframe::Renderer::Glow; - match grim::start(options, grim::app_creator(grim::gui::App::new(platform))) { + + let app = grim::gui::App::new(platform); + match grim::start(options, grim::app_creator(app)) { Ok(_) => {} Err(e) => { panic!("{}", e); @@ -129,4 +197,69 @@ fn start_desktop_gui() { } } } +} + +/// Start socket that handles data for single application instance. +#[allow(dead_code)] +#[cfg(not(target_os = "android"))] +fn start_app_socket(platform: grim::gui::platform::Desktop) { + use tor_rtcompat::BlockOn; + let runtime = tor_rtcompat::tokio::TokioNativeTlsRuntime::create().unwrap(); + let _: Result<_, _> = runtime + .block_on(async { + use interprocess::local_socket::{ + tokio::{prelude::*, Stream}, + GenericFilePath, Listener, ListenerOptions, + }; + use std::io; + use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + try_join, + }; + + // Handle incoming connection. + async fn handle_conn(conn: Stream) + -> io::Result { + let mut rec = BufReader::new(&conn); + let mut sen = &conn; + + let mut buffer = String::new(); + let send = sen.write_all(b""); + let recv = rec.read_line(&mut buffer); + + // Read data and send answer. + try_join!(recv, send)?; + + Ok(buffer) + } + + let socket_path = grim::Settings::socket_path(); + std::fs::remove_file(socket_path.clone()).unwrap(); + let name = socket_path.to_fs_name::()?; + let opts = ListenerOptions::new().name(name); + + // Create socket listener. + let listener = match opts.create_tokio() { + Err(e) if e.kind() == io::ErrorKind::AddrInUse => { + eprintln!("Socket file is occupied."); + return Err::(e); + } + x => x?, + }; + + // Handle connections. + loop { + let conn = match listener.accept().await { + Ok(c) => c, + Err(_) => continue + }; + let res = handle_conn(conn).await; + match res { + Ok(data) => { + platform.on_data(data) + }, + Err(_) => {} + } + } + }); } \ No newline at end of file diff --git a/src/settings/settings.rs b/src/settings/settings.rs index 0f7e947..5c5164c 100644 --- a/src/settings/settings.rs +++ b/src/settings/settings.rs @@ -141,6 +141,13 @@ impl Settings { path } + /// Get desktop application socket path. + pub fn socket_path() -> String { + let mut socket_path = Self::base_path(None); + socket_path.push("grim.socket"); + socket_path.to_str().unwrap().to_string() + } + /// Get configuration file path from provided name and sub-directory if needed. pub fn config_path(config_name: &str, sub_dir: Option) -> PathBuf { let mut path = Self::base_path(sub_dir); diff --git a/src/wallet/list.rs b/src/wallet/list.rs index 2298cc3..598fa3b 100644 --- a/src/wallet/list.rs +++ b/src/wallet/list.rs @@ -18,7 +18,8 @@ use grin_wallet_libwallet::Error; use crate::AppConfig; use crate::wallet::{Wallet, WalletConfig}; -/// Wrapper for [`Wallet`] list. +/// [`Wallet`] list container. +#[derive(Clone)] pub struct WalletList { /// List of wallets for [`ChainTypes::Mainnet`]. pub main_list: Vec,