@wlfi-agent/cli 1.4.14 → 1.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -0
- package/Cargo.toml +1 -1
- package/README.md +10 -2
- package/crates/vault-cli-admin/src/main.rs +21 -2
- package/crates/vault-cli-admin/src/tui.rs +634 -129
- package/crates/vault-cli-daemon/Cargo.toml +1 -0
- package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +122 -8
- package/crates/vault-cli-daemon/src/main.rs +24 -4
- package/crates/vault-cli-daemon/src/relay_sync.rs +155 -35
- package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +23 -18
- package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +6 -0
- package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +6 -0
- package/crates/vault-daemon/src/tests.rs +2 -2
- package/crates/vault-daemon/src/tests_parts/part4.rs +110 -0
- package/crates/vault-transport-unix/src/lib.rs +22 -3
- package/crates/vault-transport-xpc/src/lib.rs +20 -2
- package/dist/cli.cjs +20842 -25552
- package/dist/cli.cjs.map +1 -1
- package/package.json +18 -18
- package/packages/cache/.turbo/turbo-build.log +20 -20
- package/packages/cache/coverage/base.css +224 -0
- package/packages/cache/coverage/block-navigation.js +87 -0
- package/packages/cache/coverage/clover.xml +585 -0
- package/packages/cache/coverage/coverage-final.json +5 -0
- package/packages/cache/coverage/favicon.png +0 -0
- package/packages/cache/coverage/index.html +161 -0
- package/packages/cache/coverage/prettify.css +1 -0
- package/packages/cache/coverage/prettify.js +2 -0
- package/packages/cache/coverage/sort-arrow-sprite.png +0 -0
- package/packages/cache/coverage/sorter.js +210 -0
- package/packages/cache/coverage/src/client/index.html +116 -0
- package/packages/cache/coverage/src/client/index.ts.html +253 -0
- package/packages/cache/coverage/src/errors/index.html +116 -0
- package/packages/cache/coverage/src/errors/index.ts.html +244 -0
- package/packages/cache/coverage/src/index.html +116 -0
- package/packages/cache/coverage/src/index.ts.html +94 -0
- package/packages/cache/coverage/src/service/index.html +116 -0
- package/packages/cache/coverage/src/service/index.ts.html +2212 -0
- package/packages/cache/dist/{chunk-ALQ6H7KG.cjs → chunk-QF4XKEIA.cjs} +189 -45
- package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +1 -0
- package/packages/cache/dist/{chunk-FGJEEF5N.js → chunk-QNK6GOTI.js} +182 -38
- package/packages/cache/dist/chunk-QNK6GOTI.js.map +1 -0
- package/packages/cache/dist/index.cjs +2 -2
- package/packages/cache/dist/index.js +1 -1
- package/packages/cache/dist/service/index.cjs +2 -2
- package/packages/cache/dist/service/index.d.cts +2 -0
- package/packages/cache/dist/service/index.d.ts +2 -0
- package/packages/cache/dist/service/index.js +1 -1
- package/packages/cache/node_modules/.bin/jiti +0 -0
- package/packages/cache/node_modules/.bin/tsc +0 -0
- package/packages/cache/node_modules/.bin/tsserver +0 -0
- package/packages/cache/node_modules/.bin/tsup +0 -0
- package/packages/cache/node_modules/.bin/tsup-node +0 -0
- package/packages/cache/node_modules/.bin/tsx +0 -0
- package/packages/cache/node_modules/.bin/vitest +0 -0
- package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/packages/cache/src/service/index.test.ts +575 -0
- package/packages/cache/src/service/index.ts +234 -51
- package/packages/config/.turbo/turbo-build.log +17 -18
- package/packages/config/dist/index.cjs +0 -0
- package/packages/config/node_modules/.bin/jiti +0 -0
- package/packages/config/node_modules/.bin/tsc +2 -2
- package/packages/config/node_modules/.bin/tsserver +2 -2
- package/packages/config/node_modules/.bin/tsup +2 -2
- package/packages/config/node_modules/.bin/tsup-node +2 -2
- package/packages/config/node_modules/.bin/tsx +0 -0
- package/packages/rpc/.turbo/turbo-build.log +31 -32
- package/packages/rpc/dist/_esm-BCLXDO2R.cjs +0 -0
- package/packages/rpc/dist/ccip-OWJLAW55.cjs +0 -0
- package/packages/rpc/dist/chunk-APQIFZ3B.cjs +0 -0
- package/packages/rpc/dist/chunk-CDO2GWRD.cjs +0 -0
- package/packages/rpc/dist/chunk-QGTNTFJ7.cjs +0 -0
- package/packages/rpc/dist/chunk-TZDTAHWR.cjs +0 -0
- package/packages/rpc/dist/index.cjs +0 -0
- package/packages/rpc/dist/secp256k1-WCNM675D.cjs +0 -0
- package/packages/rpc/node_modules/.bin/jiti +0 -0
- package/packages/rpc/node_modules/.bin/tsc +2 -2
- package/packages/rpc/node_modules/.bin/tsserver +2 -2
- package/packages/rpc/node_modules/.bin/tsup +2 -2
- package/packages/rpc/node_modules/.bin/tsup-node +2 -2
- package/packages/rpc/node_modules/.bin/tsx +0 -0
- package/packages/ui/.turbo/turbo-build.log +43 -44
- package/packages/ui/node_modules/.bin/jiti +0 -0
- package/packages/ui/node_modules/.bin/tsc +0 -0
- package/packages/ui/node_modules/.bin/tsserver +0 -0
- package/packages/ui/node_modules/.bin/tsup +0 -0
- package/packages/ui/node_modules/.bin/tsup-node +0 -0
- package/packages/ui/node_modules/.bin/tsx +0 -0
- package/scripts/install-rust-binaries.mjs +164 -58
- package/scripts/launchd/install-user-daemon.sh +0 -0
- package/scripts/launchd/run-vault-daemon.sh +0 -0
- package/scripts/launchd/run-wlfi-agent-daemon.sh +0 -0
- package/scripts/launchd/uninstall-user-daemon.sh +0 -0
- package/src/cli.ts +51 -39
- package/src/lib/admin-passthrough.js +1 -0
- package/src/lib/admin-reset.js +1 -0
- package/src/lib/admin-reset.ts +26 -16
- package/src/lib/admin-setup.js +1 -0
- package/src/lib/admin-setup.ts +32 -20
- package/src/lib/agent-auth-revoke.js +1 -0
- package/src/lib/agent-auth-rotate.js +1 -0
- package/src/lib/agent-auth.js +1 -0
- package/src/lib/config-mutation.js +1 -0
- package/src/lib/launchd-assets.js +1 -0
- package/src/lib/launchd-assets.ts +29 -0
- package/src/lib/local-admin-access.js +1 -0
- package/src/lib/rust.ts +1 -1
- package/src/lib/status-repair-cli.js +1 -0
- package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +0 -1
- package/packages/cache/dist/chunk-FGJEEF5N.js.map +0 -1
|
@@ -9,7 +9,7 @@ use crossterm::execute;
|
|
|
9
9
|
use crossterm::terminal::{
|
|
10
10
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
|
11
11
|
};
|
|
12
|
-
use ratatui::backend::CrosstermBackend;
|
|
12
|
+
use ratatui::backend::{Backend, CrosstermBackend};
|
|
13
13
|
use ratatui::layout::{Constraint, Direction, Layout};
|
|
14
14
|
use ratatui::style::{Color, Modifier, Style};
|
|
15
15
|
use ratatui::text::{Line, Span};
|
|
@@ -123,6 +123,42 @@ impl View {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
127
|
+
enum PendingDiscardAction {
|
|
128
|
+
NextView,
|
|
129
|
+
PreviousView,
|
|
130
|
+
ReloadCurrentView,
|
|
131
|
+
NewTokenDraft,
|
|
132
|
+
NewNetworkDraft,
|
|
133
|
+
CycleSavedToken(i8),
|
|
134
|
+
CycleSavedNetwork(i8),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
138
|
+
enum PendingDeleteAction {
|
|
139
|
+
DeleteDestinationOverride,
|
|
140
|
+
DeleteManualApproval,
|
|
141
|
+
DeleteToken,
|
|
142
|
+
DeleteNetwork,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
146
|
+
enum MessageLevel {
|
|
147
|
+
Info,
|
|
148
|
+
Success,
|
|
149
|
+
Error,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
impl MessageLevel {
|
|
153
|
+
fn style(self) -> Style {
|
|
154
|
+
match self {
|
|
155
|
+
Self::Info => Style::default().fg(Color::Cyan),
|
|
156
|
+
Self::Success => Style::default().fg(Color::Green),
|
|
157
|
+
Self::Error => Style::default().fg(Color::Red),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
126
162
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
127
163
|
enum Field {
|
|
128
164
|
SelectedToken,
|
|
@@ -726,7 +762,12 @@ struct AppState {
|
|
|
726
762
|
network_draft: NetworkDraft,
|
|
727
763
|
show_advanced: bool,
|
|
728
764
|
print_agent_auth_token: bool,
|
|
765
|
+
token_dirty: bool,
|
|
766
|
+
network_dirty: bool,
|
|
767
|
+
pending_discard_action: Option<PendingDiscardAction>,
|
|
768
|
+
pending_delete_action: Option<PendingDeleteAction>,
|
|
729
769
|
message: Option<String>,
|
|
770
|
+
message_level: MessageLevel,
|
|
730
771
|
}
|
|
731
772
|
|
|
732
773
|
impl AppState {
|
|
@@ -760,7 +801,12 @@ impl AppState {
|
|
|
760
801
|
network_draft,
|
|
761
802
|
show_advanced: false,
|
|
762
803
|
print_agent_auth_token,
|
|
804
|
+
token_dirty: false,
|
|
805
|
+
network_dirty: false,
|
|
806
|
+
pending_discard_action: None,
|
|
807
|
+
pending_delete_action: None,
|
|
763
808
|
message: None,
|
|
809
|
+
message_level: MessageLevel::Info,
|
|
764
810
|
}
|
|
765
811
|
}
|
|
766
812
|
|
|
@@ -860,17 +906,109 @@ impl AppState {
|
|
|
860
906
|
self.visible_fields()[self.selected]
|
|
861
907
|
}
|
|
862
908
|
|
|
909
|
+
fn active_draft_dirty(&self) -> bool {
|
|
910
|
+
match self.view {
|
|
911
|
+
View::Tokens => self.token_dirty,
|
|
912
|
+
View::Networks => self.network_dirty,
|
|
913
|
+
View::Bootstrap => false,
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
fn active_draft_label(&self) -> &'static str {
|
|
918
|
+
match self.view {
|
|
919
|
+
View::Tokens => "token draft",
|
|
920
|
+
View::Networks => "network draft",
|
|
921
|
+
View::Bootstrap => "current view",
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
fn clear_pending_discard(&mut self) {
|
|
926
|
+
self.pending_discard_action = None;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
fn clear_pending_delete(&mut self) {
|
|
930
|
+
self.pending_delete_action = None;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
fn clear_message(&mut self) {
|
|
934
|
+
self.message = None;
|
|
935
|
+
self.message_level = MessageLevel::Info;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
fn set_info_message(&mut self, message: impl Into<String>) {
|
|
939
|
+
self.message = Some(message.into());
|
|
940
|
+
self.message_level = MessageLevel::Info;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
fn set_success_message(&mut self, message: impl Into<String>) {
|
|
944
|
+
self.message = Some(message.into());
|
|
945
|
+
self.message_level = MessageLevel::Success;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
fn set_error_message(&mut self, message: impl Into<String>) {
|
|
949
|
+
self.message = Some(message.into());
|
|
950
|
+
self.message_level = MessageLevel::Error;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
fn mark_token_dirty(&mut self) {
|
|
954
|
+
self.token_dirty = true;
|
|
955
|
+
self.clear_pending_discard();
|
|
956
|
+
self.clear_pending_delete();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
fn mark_network_dirty(&mut self) {
|
|
960
|
+
self.network_dirty = true;
|
|
961
|
+
self.clear_pending_discard();
|
|
962
|
+
self.clear_pending_delete();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
fn confirm_discard(&mut self, action: PendingDiscardAction) -> bool {
|
|
966
|
+
if !self.active_draft_dirty() {
|
|
967
|
+
self.clear_pending_discard();
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if self.pending_discard_action == Some(action) {
|
|
972
|
+
self.clear_pending_discard();
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
self.clear_pending_delete();
|
|
977
|
+
self.pending_discard_action = Some(action);
|
|
978
|
+
self.set_info_message(format!(
|
|
979
|
+
"unsaved changes in the {}; repeat the action to discard them or save first",
|
|
980
|
+
self.active_draft_label()
|
|
981
|
+
));
|
|
982
|
+
false
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
fn confirm_delete(&mut self, action: PendingDeleteAction, label: &str) -> bool {
|
|
986
|
+
if self.pending_delete_action == Some(action) {
|
|
987
|
+
self.clear_pending_delete();
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
self.clear_pending_discard();
|
|
992
|
+
self.pending_delete_action = Some(action);
|
|
993
|
+
self.set_info_message(format!("repeat the action to confirm deleting {label}"));
|
|
994
|
+
false
|
|
995
|
+
}
|
|
996
|
+
|
|
863
997
|
fn next_view(&mut self) {
|
|
864
998
|
self.view = self.view.next();
|
|
865
999
|
self.selected = 0;
|
|
866
|
-
self.
|
|
1000
|
+
self.clear_pending_discard();
|
|
1001
|
+
self.clear_pending_delete();
|
|
1002
|
+
self.clear_message();
|
|
867
1003
|
self.normalize_selection();
|
|
868
1004
|
}
|
|
869
1005
|
|
|
870
1006
|
fn previous_view(&mut self) {
|
|
871
1007
|
self.view = self.view.previous();
|
|
872
1008
|
self.selected = 0;
|
|
873
|
-
self.
|
|
1009
|
+
self.clear_pending_discard();
|
|
1010
|
+
self.clear_pending_delete();
|
|
1011
|
+
self.clear_message();
|
|
874
1012
|
self.normalize_selection();
|
|
875
1013
|
}
|
|
876
1014
|
|
|
@@ -906,6 +1044,42 @@ impl AppState {
|
|
|
906
1044
|
}
|
|
907
1045
|
}
|
|
908
1046
|
|
|
1047
|
+
fn request_next_view(&mut self) {
|
|
1048
|
+
if self.confirm_discard(PendingDiscardAction::NextView) {
|
|
1049
|
+
self.next_view();
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
fn request_previous_view(&mut self) {
|
|
1054
|
+
if self.confirm_discard(PendingDiscardAction::PreviousView) {
|
|
1055
|
+
self.previous_view();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
fn request_reload_current_view(&mut self) -> bool {
|
|
1060
|
+
if !self.confirm_discard(PendingDiscardAction::ReloadCurrentView) {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
self.reload_current_view();
|
|
1064
|
+
true
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
fn request_new_current_draft(&mut self) {
|
|
1068
|
+
match self.view {
|
|
1069
|
+
View::Tokens => {
|
|
1070
|
+
if self.confirm_discard(PendingDiscardAction::NewTokenDraft) {
|
|
1071
|
+
self.new_token_draft();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
View::Networks => {
|
|
1075
|
+
if self.confirm_discard(PendingDiscardAction::NewNetworkDraft) {
|
|
1076
|
+
self.new_network_draft();
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
View::Bootstrap => {}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
909
1083
|
fn load_token_draft(&mut self, token_key: Option<&str>) {
|
|
910
1084
|
self.token_draft = token_key
|
|
911
1085
|
.and_then(|token_key| {
|
|
@@ -933,6 +1107,9 @@ impl AppState {
|
|
|
933
1107
|
})
|
|
934
1108
|
})
|
|
935
1109
|
.unwrap_or_else(|| TokenDraft::blank(&self.shared_config_draft));
|
|
1110
|
+
self.token_dirty = false;
|
|
1111
|
+
self.clear_pending_discard();
|
|
1112
|
+
self.clear_pending_delete();
|
|
936
1113
|
self.normalize_selection();
|
|
937
1114
|
}
|
|
938
1115
|
|
|
@@ -963,22 +1140,81 @@ impl AppState {
|
|
|
963
1140
|
})
|
|
964
1141
|
})
|
|
965
1142
|
.unwrap_or_else(NetworkDraft::blank);
|
|
1143
|
+
self.network_dirty = false;
|
|
1144
|
+
self.clear_pending_discard();
|
|
1145
|
+
self.clear_pending_delete();
|
|
966
1146
|
self.normalize_selection();
|
|
967
1147
|
}
|
|
968
1148
|
|
|
969
1149
|
fn new_token_draft(&mut self) {
|
|
970
1150
|
self.token_draft = TokenDraft::blank(&self.shared_config_draft);
|
|
971
|
-
self.
|
|
1151
|
+
self.token_dirty = false;
|
|
1152
|
+
self.clear_pending_discard();
|
|
1153
|
+
self.clear_pending_delete();
|
|
1154
|
+
self.set_success_message("new token draft ready");
|
|
972
1155
|
}
|
|
973
1156
|
|
|
974
1157
|
fn new_network_draft(&mut self) {
|
|
975
1158
|
self.network_draft = NetworkDraft::blank();
|
|
976
|
-
self.
|
|
1159
|
+
self.network_dirty = false;
|
|
1160
|
+
self.clear_pending_discard();
|
|
1161
|
+
self.clear_pending_delete();
|
|
1162
|
+
self.set_success_message("new network draft ready");
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
fn request_delete_destination_override(&mut self) {
|
|
1166
|
+
if self.token_draft.destination_overrides.is_empty() {
|
|
1167
|
+
self.delete_destination_override();
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if self.confirm_delete(
|
|
1171
|
+
PendingDeleteAction::DeleteDestinationOverride,
|
|
1172
|
+
"the selected destination override",
|
|
1173
|
+
) {
|
|
1174
|
+
self.delete_destination_override();
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
fn request_delete_manual_approval(&mut self) {
|
|
1179
|
+
if self.token_draft.manual_approvals.is_empty() {
|
|
1180
|
+
self.delete_manual_approval();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if self.confirm_delete(
|
|
1184
|
+
PendingDeleteAction::DeleteManualApproval,
|
|
1185
|
+
"the selected manual approval policy",
|
|
1186
|
+
) {
|
|
1187
|
+
self.delete_manual_approval();
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
fn request_delete_token(&mut self) -> Result<()> {
|
|
1192
|
+
let has_candidate = self.token_draft.source_key.is_some()
|
|
1193
|
+
|| !self.token_draft.key.trim().is_empty();
|
|
1194
|
+
if !has_candidate {
|
|
1195
|
+
return self.delete_token_config();
|
|
1196
|
+
}
|
|
1197
|
+
if self.confirm_delete(PendingDeleteAction::DeleteToken, "the selected token") {
|
|
1198
|
+
return self.delete_token_config();
|
|
1199
|
+
}
|
|
1200
|
+
Ok(())
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
fn request_delete_network(&mut self) -> Result<()> {
|
|
1204
|
+
let has_candidate = self.network_draft.source_key.is_some()
|
|
1205
|
+
|| !self.network_draft.key.trim().is_empty();
|
|
1206
|
+
if !has_candidate {
|
|
1207
|
+
return self.delete_network_config();
|
|
1208
|
+
}
|
|
1209
|
+
if self.confirm_delete(PendingDeleteAction::DeleteNetwork, "the selected network") {
|
|
1210
|
+
return self.delete_network_config();
|
|
1211
|
+
}
|
|
1212
|
+
Ok(())
|
|
977
1213
|
}
|
|
978
1214
|
|
|
979
1215
|
fn step_selected(&mut self, direction: i8) {
|
|
980
1216
|
match self.selected_field() {
|
|
981
|
-
Field::SelectedToken => self.
|
|
1217
|
+
Field::SelectedToken => self.request_cycle_saved_token(direction),
|
|
982
1218
|
Field::NetworkMembership => self.cycle_available_network(direction),
|
|
983
1219
|
Field::EditingNetwork => self.cycle_selected_network_mapping(direction),
|
|
984
1220
|
Field::NetworkIsNative => {
|
|
@@ -987,6 +1223,7 @@ impl AppState {
|
|
|
987
1223
|
if network.is_native {
|
|
988
1224
|
network.address.clear();
|
|
989
1225
|
}
|
|
1226
|
+
self.mark_token_dirty();
|
|
990
1227
|
}
|
|
991
1228
|
}
|
|
992
1229
|
Field::SelectedDestinationOverride => {
|
|
@@ -1003,17 +1240,24 @@ impl AppState {
|
|
|
1003
1240
|
direction,
|
|
1004
1241
|
);
|
|
1005
1242
|
}
|
|
1006
|
-
Field::SelectedNetwork => self.
|
|
1243
|
+
Field::SelectedNetwork => self.request_cycle_saved_network(direction),
|
|
1007
1244
|
Field::ShowAdvanced => {
|
|
1008
1245
|
self.show_advanced = !self.show_advanced;
|
|
1009
1246
|
}
|
|
1010
1247
|
Field::ChainConfigUseAsActive => {
|
|
1011
1248
|
self.network_draft.use_as_active = !self.network_draft.use_as_active;
|
|
1249
|
+
self.mark_network_dirty();
|
|
1012
1250
|
}
|
|
1013
1251
|
_ => {}
|
|
1014
1252
|
}
|
|
1015
1253
|
}
|
|
1016
1254
|
|
|
1255
|
+
fn request_cycle_saved_token(&mut self, direction: i8) {
|
|
1256
|
+
if self.confirm_discard(PendingDiscardAction::CycleSavedToken(direction)) {
|
|
1257
|
+
self.cycle_saved_token(direction);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1017
1261
|
fn cycle_saved_token(&mut self, direction: i8) {
|
|
1018
1262
|
let saved = sorted_token_keys(&self.shared_config_draft);
|
|
1019
1263
|
if saved.is_empty() {
|
|
@@ -1037,7 +1281,13 @@ impl AppState {
|
|
|
1037
1281
|
} else {
|
|
1038
1282
|
let key = entries[index].clone();
|
|
1039
1283
|
self.load_token_draft(Some(&key));
|
|
1040
|
-
self.
|
|
1284
|
+
self.clear_message();
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
fn request_cycle_saved_network(&mut self, direction: i8) {
|
|
1289
|
+
if self.confirm_discard(PendingDiscardAction::CycleSavedNetwork(direction)) {
|
|
1290
|
+
self.cycle_saved_network(direction);
|
|
1041
1291
|
}
|
|
1042
1292
|
}
|
|
1043
1293
|
|
|
@@ -1064,7 +1314,7 @@ impl AppState {
|
|
|
1064
1314
|
} else {
|
|
1065
1315
|
let key = entries[index].clone();
|
|
1066
1316
|
self.load_network_draft(Some(&key));
|
|
1067
|
-
self.
|
|
1317
|
+
self.clear_message();
|
|
1068
1318
|
}
|
|
1069
1319
|
}
|
|
1070
1320
|
|
|
@@ -1186,7 +1436,12 @@ impl AppState {
|
|
|
1186
1436
|
match key.code {
|
|
1187
1437
|
KeyCode::Backspace => {
|
|
1188
1438
|
target.pop();
|
|
1189
|
-
|
|
1439
|
+
if field_uses_network_draft(selected_field) {
|
|
1440
|
+
self.mark_network_dirty();
|
|
1441
|
+
} else {
|
|
1442
|
+
self.mark_token_dirty();
|
|
1443
|
+
}
|
|
1444
|
+
self.clear_message();
|
|
1190
1445
|
}
|
|
1191
1446
|
KeyCode::Char(ch)
|
|
1192
1447
|
if !key.modifiers.contains(KeyModifiers::CONTROL)
|
|
@@ -1194,9 +1449,16 @@ impl AppState {
|
|
|
1194
1449
|
{
|
|
1195
1450
|
if is_allowed_input_char(selected_field, ch) {
|
|
1196
1451
|
target.push(ch);
|
|
1197
|
-
|
|
1452
|
+
if field_uses_network_draft(selected_field) {
|
|
1453
|
+
self.mark_network_dirty();
|
|
1454
|
+
} else {
|
|
1455
|
+
self.mark_token_dirty();
|
|
1456
|
+
}
|
|
1457
|
+
self.clear_message();
|
|
1198
1458
|
} else {
|
|
1199
|
-
self.
|
|
1459
|
+
self.set_error_message(format!(
|
|
1460
|
+
"invalid character '{ch}' for the selected field"
|
|
1461
|
+
));
|
|
1200
1462
|
}
|
|
1201
1463
|
}
|
|
1202
1464
|
_ => {}
|
|
@@ -1215,19 +1477,21 @@ impl AppState {
|
|
|
1215
1477
|
.destination_overrides
|
|
1216
1478
|
.len()
|
|
1217
1479
|
.saturating_sub(1);
|
|
1218
|
-
self.
|
|
1480
|
+
self.mark_token_dirty();
|
|
1481
|
+
self.set_success_message("destination override added");
|
|
1219
1482
|
}
|
|
1220
1483
|
|
|
1221
1484
|
fn delete_destination_override(&mut self) {
|
|
1222
1485
|
if self.token_draft.destination_overrides.is_empty() {
|
|
1223
|
-
self.
|
|
1486
|
+
self.set_info_message("no destination override is selected");
|
|
1224
1487
|
return;
|
|
1225
1488
|
}
|
|
1226
1489
|
self.token_draft
|
|
1227
1490
|
.destination_overrides
|
|
1228
1491
|
.remove(self.token_draft.selected_override);
|
|
1229
1492
|
self.token_draft.normalize();
|
|
1230
|
-
self.
|
|
1493
|
+
self.mark_token_dirty();
|
|
1494
|
+
self.set_success_message("destination override removed");
|
|
1231
1495
|
}
|
|
1232
1496
|
|
|
1233
1497
|
fn add_manual_approval(&mut self) {
|
|
@@ -1239,19 +1503,21 @@ impl AppState {
|
|
|
1239
1503
|
});
|
|
1240
1504
|
self.token_draft.selected_manual_approval =
|
|
1241
1505
|
self.token_draft.manual_approvals.len().saturating_sub(1);
|
|
1242
|
-
self.
|
|
1506
|
+
self.mark_token_dirty();
|
|
1507
|
+
self.set_success_message("manual approval policy added");
|
|
1243
1508
|
}
|
|
1244
1509
|
|
|
1245
1510
|
fn delete_manual_approval(&mut self) {
|
|
1246
1511
|
if self.token_draft.manual_approvals.is_empty() {
|
|
1247
|
-
self.
|
|
1512
|
+
self.set_info_message("no manual approval policy is selected");
|
|
1248
1513
|
return;
|
|
1249
1514
|
}
|
|
1250
1515
|
self.token_draft
|
|
1251
1516
|
.manual_approvals
|
|
1252
1517
|
.remove(self.token_draft.selected_manual_approval);
|
|
1253
1518
|
self.token_draft.normalize();
|
|
1254
|
-
self.
|
|
1519
|
+
self.mark_token_dirty();
|
|
1520
|
+
self.set_success_message("manual approval policy removed");
|
|
1255
1521
|
}
|
|
1256
1522
|
|
|
1257
1523
|
fn refresh_token_metadata(&mut self) -> Result<()> {
|
|
@@ -1292,7 +1558,8 @@ impl AppState {
|
|
|
1292
1558
|
network.chain_id = metadata.chain_id.to_string();
|
|
1293
1559
|
network.decimals = metadata.decimals.to_string();
|
|
1294
1560
|
}
|
|
1295
|
-
self.
|
|
1561
|
+
self.mark_token_dirty();
|
|
1562
|
+
self.set_success_message("token metadata refreshed from rpc");
|
|
1296
1563
|
Ok(())
|
|
1297
1564
|
}
|
|
1298
1565
|
|
|
@@ -1335,7 +1602,7 @@ impl AppState {
|
|
|
1335
1602
|
self.shared_config_draft = candidate;
|
|
1336
1603
|
self.persist_shared_config()?;
|
|
1337
1604
|
self.load_token_draft(Some(&token_key));
|
|
1338
|
-
self.
|
|
1605
|
+
self.set_success_message(format!("saved token '{}'", token_key));
|
|
1339
1606
|
Ok(())
|
|
1340
1607
|
}
|
|
1341
1608
|
|
|
@@ -1354,7 +1621,7 @@ impl AppState {
|
|
|
1354
1621
|
}
|
|
1355
1622
|
self.persist_shared_config()?;
|
|
1356
1623
|
self.load_token_draft(None);
|
|
1357
|
-
self.
|
|
1624
|
+
self.set_success_message(format!("deleted token '{}'", key));
|
|
1358
1625
|
Ok(())
|
|
1359
1626
|
}
|
|
1360
1627
|
|
|
@@ -1389,7 +1656,7 @@ impl AppState {
|
|
|
1389
1656
|
self.load_network_draft(Some(&chain_key));
|
|
1390
1657
|
let token_source_key = self.token_draft.source_key.clone();
|
|
1391
1658
|
self.load_token_draft(token_source_key.as_deref());
|
|
1392
|
-
self.
|
|
1659
|
+
self.set_success_message(format!("saved network '{}'", chain_key));
|
|
1393
1660
|
Ok(())
|
|
1394
1661
|
}
|
|
1395
1662
|
|
|
@@ -1425,7 +1692,7 @@ impl AppState {
|
|
|
1425
1692
|
}
|
|
1426
1693
|
self.persist_shared_config()?;
|
|
1427
1694
|
self.load_network_draft(None);
|
|
1428
|
-
self.
|
|
1695
|
+
self.set_success_message(format!("deleted network '{}'", key));
|
|
1429
1696
|
Ok(())
|
|
1430
1697
|
}
|
|
1431
1698
|
|
|
@@ -1539,10 +1806,8 @@ impl ResolvedLimitFields {
|
|
|
1539
1806
|
|
|
1540
1807
|
enum LoopAction {
|
|
1541
1808
|
Continue,
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
success_message: String,
|
|
1545
|
-
},
|
|
1809
|
+
RefreshTokenMetadata,
|
|
1810
|
+
SaveTokenAndApply,
|
|
1546
1811
|
ApplyAndExit(Box<BootstrapParams>),
|
|
1547
1812
|
Cancel,
|
|
1548
1813
|
}
|
|
@@ -1550,7 +1815,7 @@ enum LoopAction {
|
|
|
1550
1815
|
pub(crate) fn run_bootstrap_tui<T>(
|
|
1551
1816
|
shared_config: &WlfiConfig,
|
|
1552
1817
|
print_agent_auth_token: bool,
|
|
1553
|
-
on_apply: impl FnMut(BootstrapParams) -> Result<T>,
|
|
1818
|
+
on_apply: impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
|
|
1554
1819
|
) -> Result<Option<T>> {
|
|
1555
1820
|
enable_raw_mode().context("failed to enable raw mode")?;
|
|
1556
1821
|
let mut stdout = io::stdout();
|
|
@@ -1580,17 +1845,70 @@ fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Re
|
|
|
1580
1845
|
Ok(())
|
|
1581
1846
|
}
|
|
1582
1847
|
|
|
1583
|
-
fn
|
|
1584
|
-
terminal
|
|
1848
|
+
fn render_frame<B: Backend>(terminal: &mut Terminal<B>, app: &AppState) -> Result<()> {
|
|
1849
|
+
terminal
|
|
1850
|
+
.draw(|frame| draw_ui(frame, app))
|
|
1851
|
+
.context("failed to render tui frame")?;
|
|
1852
|
+
Ok(())
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
fn refresh_metadata_with_progress<B: Backend>(
|
|
1856
|
+
terminal: &mut Terminal<B>,
|
|
1857
|
+
app: &mut AppState,
|
|
1858
|
+
) -> Result<()> {
|
|
1859
|
+
app.set_info_message("refreshing token metadata from rpc");
|
|
1860
|
+
render_frame(terminal, app)?;
|
|
1861
|
+
app.refresh_token_metadata()
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
fn apply_with_progress<T, B: Backend>(
|
|
1865
|
+
terminal: &mut Terminal<B>,
|
|
1866
|
+
app: &mut AppState,
|
|
1867
|
+
params: BootstrapParams,
|
|
1868
|
+
on_apply: &mut impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
|
|
1869
|
+
) -> Result<T> {
|
|
1870
|
+
app.set_info_message("applying wallet changes");
|
|
1871
|
+
render_frame(terminal, app)?;
|
|
1872
|
+
|
|
1873
|
+
let mut on_status = |message: &str| -> Result<()> {
|
|
1874
|
+
app.set_info_message(message);
|
|
1875
|
+
render_frame(terminal, app)
|
|
1876
|
+
};
|
|
1877
|
+
|
|
1878
|
+
on_apply(params, &mut on_status)
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
fn run_save_token_and_apply<T, B: Backend>(
|
|
1882
|
+
terminal: &mut Terminal<B>,
|
|
1883
|
+
app: &mut AppState,
|
|
1884
|
+
on_apply: &mut impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
|
|
1885
|
+
) -> Result<(T, String)> {
|
|
1886
|
+
if app.selected_token_requires_metadata_refresh() {
|
|
1887
|
+
refresh_metadata_with_progress(terminal, app)?;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
app.save_token_config()?;
|
|
1891
|
+
let success_message = app
|
|
1892
|
+
.message
|
|
1893
|
+
.clone()
|
|
1894
|
+
.unwrap_or_else(|| "saved token".to_string());
|
|
1895
|
+
|
|
1896
|
+
let params = app.build_params().map_err(|err| {
|
|
1897
|
+
anyhow!("{success_message} but failed to apply to wallet: {err}")
|
|
1898
|
+
})?;
|
|
1899
|
+
let output = apply_with_progress(terminal, app, params, on_apply)?;
|
|
1900
|
+
Ok((output, format!("{success_message} and applied to wallet")))
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
fn run_event_loop<T, B: Backend>(
|
|
1904
|
+
terminal: &mut Terminal<B>,
|
|
1585
1905
|
mut app: AppState,
|
|
1586
|
-
mut on_apply: impl FnMut(BootstrapParams) -> Result<T>,
|
|
1906
|
+
mut on_apply: impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
|
|
1587
1907
|
) -> Result<Option<T>> {
|
|
1588
1908
|
let mut last_output = None;
|
|
1589
1909
|
loop {
|
|
1590
1910
|
app.normalize_selection();
|
|
1591
|
-
terminal
|
|
1592
|
-
.draw(|frame| draw_ui(frame, &app))
|
|
1593
|
-
.context("failed to render tui frame")?;
|
|
1911
|
+
render_frame(terminal, &app)?;
|
|
1594
1912
|
|
|
1595
1913
|
if !event::poll(Duration::from_millis(250)).context("failed to poll terminal events")? {
|
|
1596
1914
|
continue;
|
|
@@ -1605,22 +1923,33 @@ fn run_event_loop<T>(
|
|
|
1605
1923
|
|
|
1606
1924
|
match handle_key_event(&mut app, key)? {
|
|
1607
1925
|
LoopAction::Continue => {}
|
|
1608
|
-
LoopAction::
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1926
|
+
LoopAction::RefreshTokenMetadata => {
|
|
1927
|
+
if let Err(err) = refresh_metadata_with_progress(terminal, &mut app) {
|
|
1928
|
+
app.set_error_message(err.to_string());
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
LoopAction::SaveTokenAndApply => match run_save_token_and_apply(
|
|
1932
|
+
terminal,
|
|
1933
|
+
&mut app,
|
|
1934
|
+
&mut on_apply,
|
|
1935
|
+
) {
|
|
1936
|
+
Ok((output, success_message)) => {
|
|
1613
1937
|
last_output = Some(output);
|
|
1614
|
-
app.
|
|
1938
|
+
app.set_success_message(success_message);
|
|
1615
1939
|
}
|
|
1616
1940
|
Err(err) => {
|
|
1617
|
-
app.
|
|
1941
|
+
app.set_error_message(err.to_string());
|
|
1618
1942
|
}
|
|
1619
1943
|
},
|
|
1620
|
-
LoopAction::ApplyAndExit(params) => match
|
|
1944
|
+
LoopAction::ApplyAndExit(params) => match apply_with_progress(
|
|
1945
|
+
terminal,
|
|
1946
|
+
&mut app,
|
|
1947
|
+
*params,
|
|
1948
|
+
&mut on_apply,
|
|
1949
|
+
) {
|
|
1621
1950
|
Ok(output) => return Ok(Some(output)),
|
|
1622
1951
|
Err(err) => {
|
|
1623
|
-
app.
|
|
1952
|
+
app.set_error_message(err.to_string());
|
|
1624
1953
|
}
|
|
1625
1954
|
},
|
|
1626
1955
|
LoopAction::Cancel => return Ok(last_output),
|
|
@@ -1633,24 +1962,21 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
|
1633
1962
|
match app.build_params() {
|
|
1634
1963
|
Ok(params) => return Ok(LoopAction::ApplyAndExit(Box::new(params))),
|
|
1635
1964
|
Err(err) => {
|
|
1636
|
-
app.
|
|
1965
|
+
app.set_error_message(err.to_string());
|
|
1637
1966
|
return Ok(LoopAction::Continue);
|
|
1638
1967
|
}
|
|
1639
1968
|
}
|
|
1640
1969
|
}
|
|
1641
1970
|
|
|
1642
1971
|
if key.code == KeyCode::Char('r') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
1643
|
-
app.
|
|
1644
|
-
|
|
1972
|
+
if app.request_reload_current_view() {
|
|
1973
|
+
app.set_success_message("reloaded saved data into the current draft");
|
|
1974
|
+
}
|
|
1645
1975
|
return Ok(LoopAction::Continue);
|
|
1646
1976
|
}
|
|
1647
1977
|
|
|
1648
1978
|
if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
1649
|
-
|
|
1650
|
-
View::Tokens => app.new_token_draft(),
|
|
1651
|
-
View::Networks => app.new_network_draft(),
|
|
1652
|
-
View::Bootstrap => {}
|
|
1653
|
-
}
|
|
1979
|
+
app.request_new_current_draft();
|
|
1654
1980
|
return Ok(LoopAction::Continue);
|
|
1655
1981
|
}
|
|
1656
1982
|
|
|
@@ -1676,20 +2002,20 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
|
1676
2002
|
KeyCode::Char('q') if key.modifiers.is_empty() => return Ok(LoopAction::Cancel),
|
|
1677
2003
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
1678
2004
|
app.select_next();
|
|
1679
|
-
app.
|
|
2005
|
+
app.clear_message();
|
|
1680
2006
|
return Ok(LoopAction::Continue);
|
|
1681
2007
|
}
|
|
1682
2008
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
1683
2009
|
app.select_prev();
|
|
1684
|
-
app.
|
|
2010
|
+
app.clear_message();
|
|
1685
2011
|
return Ok(LoopAction::Continue);
|
|
1686
2012
|
}
|
|
1687
2013
|
KeyCode::BackTab => {
|
|
1688
|
-
app.
|
|
2014
|
+
app.request_previous_view();
|
|
1689
2015
|
return Ok(LoopAction::Continue);
|
|
1690
2016
|
}
|
|
1691
2017
|
KeyCode::Tab => {
|
|
1692
|
-
app.
|
|
2018
|
+
app.request_next_view();
|
|
1693
2019
|
return Ok(LoopAction::Continue);
|
|
1694
2020
|
}
|
|
1695
2021
|
KeyCode::Home => {
|
|
@@ -1701,13 +2027,13 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
|
1701
2027
|
return Ok(LoopAction::Continue);
|
|
1702
2028
|
}
|
|
1703
2029
|
KeyCode::Left | KeyCode::Char('h') => {
|
|
2030
|
+
app.clear_message();
|
|
1704
2031
|
app.step_selected(-1);
|
|
1705
|
-
app.message = None;
|
|
1706
2032
|
return Ok(LoopAction::Continue);
|
|
1707
2033
|
}
|
|
1708
2034
|
KeyCode::Right | KeyCode::Char('l') => {
|
|
2035
|
+
app.clear_message();
|
|
1709
2036
|
app.step_selected(1);
|
|
1710
|
-
app.message = None;
|
|
1711
2037
|
return Ok(LoopAction::Continue);
|
|
1712
2038
|
}
|
|
1713
2039
|
KeyCode::Char(' ') => match app.selected_field() {
|
|
@@ -1716,9 +2042,10 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
|
1716
2042
|
.token_draft
|
|
1717
2043
|
.toggle_network_membership(&app.shared_config_draft)
|
|
1718
2044
|
{
|
|
1719
|
-
app.
|
|
2045
|
+
app.set_error_message(err.to_string());
|
|
1720
2046
|
} else {
|
|
1721
|
-
app.
|
|
2047
|
+
app.mark_token_dirty();
|
|
2048
|
+
app.set_success_message("updated token network selection");
|
|
1722
2049
|
}
|
|
1723
2050
|
return Ok(LoopAction::Continue);
|
|
1724
2051
|
}
|
|
@@ -1736,18 +2063,18 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
|
1736
2063
|
_ => {}
|
|
1737
2064
|
},
|
|
1738
2065
|
KeyCode::Char('[') if key.modifiers.is_empty() => {
|
|
1739
|
-
app.
|
|
2066
|
+
app.request_previous_view();
|
|
1740
2067
|
return Ok(LoopAction::Continue);
|
|
1741
2068
|
}
|
|
1742
2069
|
KeyCode::Char(']') if key.modifiers.is_empty() => {
|
|
1743
|
-
app.
|
|
2070
|
+
app.request_next_view();
|
|
1744
2071
|
return Ok(LoopAction::Continue);
|
|
1745
2072
|
}
|
|
1746
2073
|
KeyCode::Enter => match app.selected_field() {
|
|
1747
2074
|
Field::Execute => match app.build_params() {
|
|
1748
2075
|
Ok(params) => return Ok(LoopAction::ApplyAndExit(Box::new(params))),
|
|
1749
2076
|
Err(err) => {
|
|
1750
|
-
app.
|
|
2077
|
+
app.set_error_message(err.to_string());
|
|
1751
2078
|
return Ok(LoopAction::Continue);
|
|
1752
2079
|
}
|
|
1753
2080
|
},
|
|
@@ -1756,24 +2083,22 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
|
1756
2083
|
.token_draft
|
|
1757
2084
|
.toggle_network_membership(&app.shared_config_draft)
|
|
1758
2085
|
{
|
|
1759
|
-
app.
|
|
2086
|
+
app.set_error_message(err.to_string());
|
|
1760
2087
|
} else {
|
|
1761
|
-
app.
|
|
2088
|
+
app.mark_token_dirty();
|
|
2089
|
+
app.set_success_message("updated token network selection");
|
|
1762
2090
|
}
|
|
1763
2091
|
return Ok(LoopAction::Continue);
|
|
1764
2092
|
}
|
|
1765
2093
|
Field::RefreshTokenMetadata => {
|
|
1766
|
-
|
|
1767
|
-
app.message = Some(err.to_string());
|
|
1768
|
-
}
|
|
1769
|
-
return Ok(LoopAction::Continue);
|
|
2094
|
+
return Ok(LoopAction::RefreshTokenMetadata);
|
|
1770
2095
|
}
|
|
1771
2096
|
Field::DestinationOverrides => {
|
|
1772
2097
|
app.add_destination_override();
|
|
1773
2098
|
return Ok(LoopAction::Continue);
|
|
1774
2099
|
}
|
|
1775
2100
|
Field::DeleteDestinationOverride => {
|
|
1776
|
-
app.
|
|
2101
|
+
app.request_delete_destination_override();
|
|
1777
2102
|
return Ok(LoopAction::Continue);
|
|
1778
2103
|
}
|
|
1779
2104
|
Field::ManualApprovals => {
|
|
@@ -1781,54 +2106,27 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
|
1781
2106
|
return Ok(LoopAction::Continue);
|
|
1782
2107
|
}
|
|
1783
2108
|
Field::DeleteManualApproval => {
|
|
1784
|
-
app.
|
|
2109
|
+
app.request_delete_manual_approval();
|
|
1785
2110
|
return Ok(LoopAction::Continue);
|
|
1786
2111
|
}
|
|
1787
2112
|
Field::SaveToken => {
|
|
1788
|
-
|
|
1789
|
-
if let Err(err) = app.refresh_token_metadata() {
|
|
1790
|
-
app.message = Some(err.to_string());
|
|
1791
|
-
return Ok(LoopAction::Continue);
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
if let Err(err) = app.save_token_config() {
|
|
1795
|
-
app.message = Some(err.to_string());
|
|
1796
|
-
return Ok(LoopAction::Continue);
|
|
1797
|
-
}
|
|
1798
|
-
let success_message = app
|
|
1799
|
-
.message
|
|
1800
|
-
.clone()
|
|
1801
|
-
.unwrap_or_else(|| "saved token".to_string());
|
|
1802
|
-
match app.build_params() {
|
|
1803
|
-
Ok(params) => {
|
|
1804
|
-
return Ok(LoopAction::ApplyAndContinue {
|
|
1805
|
-
params: Box::new(params),
|
|
1806
|
-
success_message: format!("{success_message} and applied to wallet"),
|
|
1807
|
-
});
|
|
1808
|
-
}
|
|
1809
|
-
Err(err) => {
|
|
1810
|
-
app.message = Some(format!(
|
|
1811
|
-
"{success_message} but failed to apply to wallet: {err}"
|
|
1812
|
-
));
|
|
1813
|
-
return Ok(LoopAction::Continue);
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
2113
|
+
return Ok(LoopAction::SaveTokenAndApply);
|
|
1816
2114
|
}
|
|
1817
2115
|
Field::DeleteToken => {
|
|
1818
|
-
if let Err(err) = app.
|
|
1819
|
-
app.
|
|
2116
|
+
if let Err(err) = app.request_delete_token() {
|
|
2117
|
+
app.set_error_message(err.to_string());
|
|
1820
2118
|
}
|
|
1821
2119
|
return Ok(LoopAction::Continue);
|
|
1822
2120
|
}
|
|
1823
2121
|
Field::SaveNetwork => {
|
|
1824
2122
|
if let Err(err) = app.save_network_config() {
|
|
1825
|
-
app.
|
|
2123
|
+
app.set_error_message(err.to_string());
|
|
1826
2124
|
}
|
|
1827
2125
|
return Ok(LoopAction::Continue);
|
|
1828
2126
|
}
|
|
1829
2127
|
Field::DeleteNetwork => {
|
|
1830
|
-
if let Err(err) = app.
|
|
1831
|
-
app.
|
|
2128
|
+
if let Err(err) = app.request_delete_network() {
|
|
2129
|
+
app.set_error_message(err.to_string());
|
|
1832
2130
|
}
|
|
1833
2131
|
return Ok(LoopAction::Continue);
|
|
1834
2132
|
}
|
|
@@ -1942,11 +2240,7 @@ fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &AppState) {
|
|
|
1942
2240
|
let default_message =
|
|
1943
2241
|
"Ready. Start with tokens; saved tokens expand across all selected networks at bootstrap.";
|
|
1944
2242
|
let message = app.message.as_deref().unwrap_or(default_message);
|
|
1945
|
-
let style =
|
|
1946
|
-
Style::default().fg(Color::Red)
|
|
1947
|
-
} else {
|
|
1948
|
-
Style::default().fg(Color::Green)
|
|
1949
|
-
};
|
|
2243
|
+
let style = status_message_style(app);
|
|
1950
2244
|
frame.render_widget(
|
|
1951
2245
|
Paragraph::new(Line::from(Span::styled(message, style)))
|
|
1952
2246
|
.block(Block::default().borders(Borders::ALL).title("Status")),
|
|
@@ -1954,6 +2248,14 @@ fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &AppState) {
|
|
|
1954
2248
|
);
|
|
1955
2249
|
}
|
|
1956
2250
|
|
|
2251
|
+
fn status_message_style(app: &AppState) -> Style {
|
|
2252
|
+
if app.message.is_some() {
|
|
2253
|
+
app.message_level.style()
|
|
2254
|
+
} else {
|
|
2255
|
+
Style::default().fg(Color::Green)
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
1957
2259
|
fn build_help_lines(view: View) -> Vec<Line<'static>> {
|
|
1958
2260
|
let mut lines = vec![
|
|
1959
2261
|
Line::from("Views: Tab/Shift+Tab or ]/["),
|
|
@@ -2311,7 +2613,7 @@ fn field_value(app: &AppState, field: Field) -> String {
|
|
|
2311
2613
|
.get(app.token_draft.selected_override)
|
|
2312
2614
|
.map(|item| display_unlimited_if_empty(&item.limits.per_tx_max_calldata_bytes))
|
|
2313
2615
|
.unwrap_or_else(|| "n/a".to_string()),
|
|
2314
|
-
Field::DeleteDestinationOverride => "press Enter".to_string(),
|
|
2616
|
+
Field::DeleteDestinationOverride => "press Enter; repeat to confirm".to_string(),
|
|
2315
2617
|
Field::ManualApprovals => format!(
|
|
2316
2618
|
"{} saved draft(s) — press Enter",
|
|
2317
2619
|
app.token_draft.manual_approvals.len()
|
|
@@ -2346,9 +2648,9 @@ fn field_value(app: &AppState, field: Field) -> String {
|
|
|
2346
2648
|
.get(app.token_draft.selected_manual_approval)
|
|
2347
2649
|
.map(|item| blank_if_empty(&item.priority))
|
|
2348
2650
|
.unwrap_or_else(|| "n/a".to_string()),
|
|
2349
|
-
Field::DeleteManualApproval => "press Enter".to_string(),
|
|
2651
|
+
Field::DeleteManualApproval => "press Enter; repeat to confirm".to_string(),
|
|
2350
2652
|
Field::SaveToken => "press Enter".to_string(),
|
|
2351
|
-
Field::DeleteToken => "press Enter".to_string(),
|
|
2653
|
+
Field::DeleteToken => "press Enter; repeat to confirm".to_string(),
|
|
2352
2654
|
Field::SelectedNetwork => app
|
|
2353
2655
|
.network_draft
|
|
2354
2656
|
.source_key
|
|
@@ -2360,7 +2662,7 @@ fn field_value(app: &AppState, field: Field) -> String {
|
|
|
2360
2662
|
Field::ChainConfigRpcUrl => blank_if_empty(&app.network_draft.rpc_url),
|
|
2361
2663
|
Field::ChainConfigUseAsActive => bool_label(app.network_draft.use_as_active).to_string(),
|
|
2362
2664
|
Field::SaveNetwork => "press Enter".to_string(),
|
|
2363
|
-
Field::DeleteNetwork => "press Enter".to_string(),
|
|
2665
|
+
Field::DeleteNetwork => "press Enter; repeat to confirm".to_string(),
|
|
2364
2666
|
Field::Execute => "press Enter".to_string(),
|
|
2365
2667
|
}
|
|
2366
2668
|
}
|
|
@@ -2381,6 +2683,16 @@ fn display_unlimited_if_empty(value: &str) -> String {
|
|
|
2381
2683
|
}
|
|
2382
2684
|
}
|
|
2383
2685
|
|
|
2686
|
+
fn field_uses_network_draft(field: Field) -> bool {
|
|
2687
|
+
matches!(
|
|
2688
|
+
field,
|
|
2689
|
+
Field::ChainConfigKey
|
|
2690
|
+
| Field::ChainConfigId
|
|
2691
|
+
| Field::ChainConfigName
|
|
2692
|
+
| Field::ChainConfigRpcUrl
|
|
2693
|
+
)
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2384
2696
|
fn render_network_membership_value(token_draft: &TokenDraft, config: &WlfiConfig) -> String {
|
|
2385
2697
|
let available = sorted_chain_keys(config);
|
|
2386
2698
|
if available.is_empty() {
|
|
@@ -3089,13 +3401,14 @@ fn validate_optional_overlay_limit(
|
|
|
3089
3401
|
#[cfg(test)]
|
|
3090
3402
|
mod tests {
|
|
3091
3403
|
use super::{
|
|
3092
|
-
build_bootstrap_panel_lines, build_token_panel_lines,
|
|
3093
|
-
resolve_all_token_manual_approval_policies, resolve_all_token_policies,
|
|
3094
|
-
ChainProfile, Field, KeyCode, KeyEvent, KeyModifiers,
|
|
3095
|
-
|
|
3096
|
-
TokenProfile, View, WlfiConfig,
|
|
3404
|
+
apply_with_progress, build_bootstrap_panel_lines, build_token_panel_lines,
|
|
3405
|
+
handle_key_event, resolve_all_token_manual_approval_policies, resolve_all_token_policies,
|
|
3406
|
+
status_message_style, AppState, ChainProfile, Field, KeyCode, KeyEvent, KeyModifiers,
|
|
3407
|
+
LoopAction, MessageLevel, TokenChainProfile, TokenDestinationOverrideProfile,
|
|
3408
|
+
TokenManualApprovalProfile, TokenPolicyProfile, TokenProfile, View, WlfiConfig,
|
|
3097
3409
|
};
|
|
3098
3410
|
use crate::shared_config::WalletProfile;
|
|
3411
|
+
use ratatui::{backend::TestBackend, style::Color, Terminal};
|
|
3099
3412
|
use std::collections::BTreeMap;
|
|
3100
3413
|
use std::fs;
|
|
3101
3414
|
use uuid::Uuid;
|
|
@@ -3334,7 +3647,7 @@ mod tests {
|
|
|
3334
3647
|
}
|
|
3335
3648
|
|
|
3336
3649
|
#[test]
|
|
3337
|
-
fn
|
|
3650
|
+
fn save_token_defers_apply_to_event_loop_when_metadata_is_complete() {
|
|
3338
3651
|
let config = sample_config();
|
|
3339
3652
|
let mut app = AppState::from_shared_config(&config, false);
|
|
3340
3653
|
let config_root =
|
|
@@ -3350,21 +3663,65 @@ mod tests {
|
|
|
3350
3663
|
let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
3351
3664
|
.expect("handle");
|
|
3352
3665
|
|
|
3353
|
-
|
|
3354
|
-
LoopAction::ApplyAndContinue {
|
|
3355
|
-
params,
|
|
3356
|
-
success_message,
|
|
3357
|
-
} => {
|
|
3358
|
-
assert_eq!(params.token_policies.len(), 2);
|
|
3359
|
-
assert!(success_message.contains("saved token 'usdc'"));
|
|
3360
|
-
assert!(success_message.contains("applied to wallet"));
|
|
3361
|
-
}
|
|
3362
|
-
_ => panic!("expected save token to apply and continue"),
|
|
3363
|
-
}
|
|
3666
|
+
assert!(matches!(action, LoopAction::SaveTokenAndApply));
|
|
3364
3667
|
|
|
3365
3668
|
fs::remove_dir_all(config_root).expect("cleanup temp config root");
|
|
3366
3669
|
}
|
|
3367
3670
|
|
|
3671
|
+
#[test]
|
|
3672
|
+
fn refresh_metadata_action_is_deferred_to_event_loop_for_progress_rendering() {
|
|
3673
|
+
let config = sample_config();
|
|
3674
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
3675
|
+
app.selected = app
|
|
3676
|
+
.visible_fields()
|
|
3677
|
+
.iter()
|
|
3678
|
+
.position(|field| *field == Field::RefreshTokenMetadata)
|
|
3679
|
+
.expect("refresh metadata field");
|
|
3680
|
+
|
|
3681
|
+
let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
3682
|
+
.expect("handle");
|
|
3683
|
+
|
|
3684
|
+
assert!(matches!(action, LoopAction::RefreshTokenMetadata));
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
#[test]
|
|
3688
|
+
fn apply_with_progress_surfaces_bootstrap_status_messages() {
|
|
3689
|
+
let mut terminal = Terminal::new(TestBackend::new(120, 40)).expect("terminal");
|
|
3690
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3691
|
+
let params = app.build_params().expect("params");
|
|
3692
|
+
|
|
3693
|
+
let output = apply_with_progress(&mut terminal, &mut app, params, &mut |params, on_status| {
|
|
3694
|
+
assert_eq!(params.token_policies.len(), 2);
|
|
3695
|
+
on_status("initializing daemon")?;
|
|
3696
|
+
on_status("registering spending policies")?;
|
|
3697
|
+
Ok("ok".to_string())
|
|
3698
|
+
})
|
|
3699
|
+
.expect("apply succeeds");
|
|
3700
|
+
|
|
3701
|
+
assert_eq!(output, "ok");
|
|
3702
|
+
assert_eq!(
|
|
3703
|
+
app.message.as_deref(),
|
|
3704
|
+
Some("registering spending policies")
|
|
3705
|
+
);
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
#[test]
|
|
3709
|
+
fn status_message_style_uses_message_severity() {
|
|
3710
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3711
|
+
|
|
3712
|
+
app.set_info_message("loading");
|
|
3713
|
+
assert_eq!(app.message_level, MessageLevel::Info);
|
|
3714
|
+
assert_eq!(status_message_style(&app).fg, Some(Color::Cyan));
|
|
3715
|
+
|
|
3716
|
+
app.set_success_message("saved");
|
|
3717
|
+
assert_eq!(app.message_level, MessageLevel::Success);
|
|
3718
|
+
assert_eq!(status_message_style(&app).fg, Some(Color::Green));
|
|
3719
|
+
|
|
3720
|
+
app.set_error_message("failed");
|
|
3721
|
+
assert_eq!(app.message_level, MessageLevel::Error);
|
|
3722
|
+
assert_eq!(status_message_style(&app).fg, Some(Color::Red));
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3368
3725
|
#[test]
|
|
3369
3726
|
fn advanced_fields_are_hidden_by_default() {
|
|
3370
3727
|
let app = AppState::from_shared_config(&sample_config(), false);
|
|
@@ -3407,4 +3764,152 @@ mod tests {
|
|
|
3407
3764
|
let app = AppState::from_shared_config(&WlfiConfig::default(), false);
|
|
3408
3765
|
assert_eq!(super::field_value(&app, Field::NetworkAddress), "native");
|
|
3409
3766
|
}
|
|
3767
|
+
|
|
3768
|
+
#[test]
|
|
3769
|
+
fn tab_requires_confirmation_before_discarding_dirty_token_draft() {
|
|
3770
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3771
|
+
app.selected = app
|
|
3772
|
+
.visible_fields()
|
|
3773
|
+
.iter()
|
|
3774
|
+
.position(|field| *field == Field::TokenKey)
|
|
3775
|
+
.expect("token key field");
|
|
3776
|
+
|
|
3777
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
|
|
3778
|
+
.expect("edit token key");
|
|
3779
|
+
assert!(app.token_dirty);
|
|
3780
|
+
|
|
3781
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
|
3782
|
+
.expect("first tab");
|
|
3783
|
+
assert_eq!(app.view, View::Tokens);
|
|
3784
|
+
assert!(app
|
|
3785
|
+
.message
|
|
3786
|
+
.as_deref()
|
|
3787
|
+
.expect("discard warning")
|
|
3788
|
+
.contains("unsaved changes in the token draft"));
|
|
3789
|
+
|
|
3790
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
|
3791
|
+
.expect("second tab");
|
|
3792
|
+
assert_eq!(app.view, View::Networks);
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
#[test]
|
|
3796
|
+
fn reload_requires_confirmation_before_discarding_dirty_network_draft() {
|
|
3797
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3798
|
+
app.view = View::Networks;
|
|
3799
|
+
let original_name = app.network_draft.name.clone();
|
|
3800
|
+
app.selected = app
|
|
3801
|
+
.visible_fields()
|
|
3802
|
+
.iter()
|
|
3803
|
+
.position(|field| *field == Field::ChainConfigName)
|
|
3804
|
+
.expect("chain config name field");
|
|
3805
|
+
|
|
3806
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
|
|
3807
|
+
.expect("edit network name");
|
|
3808
|
+
assert!(app.network_dirty);
|
|
3809
|
+
assert!(app.network_draft.name.ends_with('x'));
|
|
3810
|
+
|
|
3811
|
+
let _ = handle_key_event(
|
|
3812
|
+
&mut app,
|
|
3813
|
+
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
|
3814
|
+
)
|
|
3815
|
+
.expect("first reload");
|
|
3816
|
+
assert!(app.network_draft.name.ends_with('x'));
|
|
3817
|
+
assert!(app
|
|
3818
|
+
.message
|
|
3819
|
+
.as_deref()
|
|
3820
|
+
.expect("discard warning")
|
|
3821
|
+
.contains("unsaved changes in the network draft"));
|
|
3822
|
+
|
|
3823
|
+
let _ = handle_key_event(
|
|
3824
|
+
&mut app,
|
|
3825
|
+
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
|
3826
|
+
)
|
|
3827
|
+
.expect("second reload");
|
|
3828
|
+
assert_eq!(app.network_draft.name, original_name);
|
|
3829
|
+
assert!(!app.network_dirty);
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
#[test]
|
|
3833
|
+
fn cycling_saved_token_requires_confirmation_when_current_draft_is_dirty() {
|
|
3834
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3835
|
+
app.selected = app
|
|
3836
|
+
.visible_fields()
|
|
3837
|
+
.iter()
|
|
3838
|
+
.position(|field| *field == Field::TokenKey)
|
|
3839
|
+
.expect("token key field");
|
|
3840
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
|
|
3841
|
+
.expect("edit token key");
|
|
3842
|
+
|
|
3843
|
+
app.selected = app
|
|
3844
|
+
.visible_fields()
|
|
3845
|
+
.iter()
|
|
3846
|
+
.position(|field| *field == Field::SelectedToken)
|
|
3847
|
+
.expect("selected token field");
|
|
3848
|
+
|
|
3849
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
|
|
3850
|
+
.expect("first cycle");
|
|
3851
|
+
assert_eq!(app.token_draft.source_key.as_deref(), Some("usdc"));
|
|
3852
|
+
assert!(app
|
|
3853
|
+
.message
|
|
3854
|
+
.as_deref()
|
|
3855
|
+
.expect("discard warning")
|
|
3856
|
+
.contains("unsaved changes in the token draft"));
|
|
3857
|
+
|
|
3858
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
|
|
3859
|
+
.expect("second cycle");
|
|
3860
|
+
assert!(app.token_draft.source_key.is_none());
|
|
3861
|
+
assert!(!app.token_dirty);
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3864
|
+
#[test]
|
|
3865
|
+
fn delete_manual_approval_requires_confirmation() {
|
|
3866
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3867
|
+
app.selected = app
|
|
3868
|
+
.visible_fields()
|
|
3869
|
+
.iter()
|
|
3870
|
+
.position(|field| *field == Field::DeleteManualApproval)
|
|
3871
|
+
.expect("delete manual approval field");
|
|
3872
|
+
|
|
3873
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
3874
|
+
.expect("first delete");
|
|
3875
|
+
assert_eq!(app.token_draft.manual_approvals.len(), 1);
|
|
3876
|
+
assert!(app
|
|
3877
|
+
.message
|
|
3878
|
+
.as_deref()
|
|
3879
|
+
.expect("confirmation")
|
|
3880
|
+
.contains("repeat the action to confirm deleting the selected manual approval policy"));
|
|
3881
|
+
|
|
3882
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
3883
|
+
.expect("second delete");
|
|
3884
|
+
assert!(app.token_draft.manual_approvals.is_empty());
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
#[test]
|
|
3888
|
+
fn delete_token_requires_confirmation() {
|
|
3889
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3890
|
+
let config_root =
|
|
3891
|
+
std::env::temp_dir().join(format!("wlfi-agent-admin-delete-token-{}", Uuid::new_v4()));
|
|
3892
|
+
fs::create_dir_all(&config_root).expect("create temp config root");
|
|
3893
|
+
app.config_path = config_root.join("config.json");
|
|
3894
|
+
app.selected = app
|
|
3895
|
+
.visible_fields()
|
|
3896
|
+
.iter()
|
|
3897
|
+
.position(|field| *field == Field::DeleteToken)
|
|
3898
|
+
.expect("delete token field");
|
|
3899
|
+
|
|
3900
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
3901
|
+
.expect("first delete");
|
|
3902
|
+
assert!(app.shared_config_draft.tokens.contains_key("usdc"));
|
|
3903
|
+
assert!(app
|
|
3904
|
+
.message
|
|
3905
|
+
.as_deref()
|
|
3906
|
+
.expect("confirmation")
|
|
3907
|
+
.contains("repeat the action to confirm deleting the selected token"));
|
|
3908
|
+
|
|
3909
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
3910
|
+
.expect("second delete");
|
|
3911
|
+
assert!(!app.shared_config_draft.tokens.contains_key("usdc"));
|
|
3912
|
+
|
|
3913
|
+
fs::remove_dir_all(config_root).expect("cleanup temp config root");
|
|
3914
|
+
}
|
|
3410
3915
|
}
|