1use std::sync::Arc;
2
3use tokio::runtime::Runtime;
4use tokio::sync::mpsc;
5
6use opcua::types::NodeId;
7
8use crate::client::{UaClient, parse_attribute_value, parse_variant};
9use crate::messages::{UiAction, UiUpdate};
10use crate::model::{AppModel, AttributeEditState, ConnectionState, DetailTab, MethodCallState};
11use crate::types::{AuthSpec, EndpointInfo, SubscriptionRow, ValueTree};
12
13#[derive(Debug, Clone, Copy)]
14pub enum FilePickTarget {
15 CertPath,
16 KeyPath,
17}
18
19pub trait FrontendCtx: Clone + Send + Sync + 'static {
20 fn request_repaint(&self);
21 fn set_clipboard(&self, text: &str);
22 fn pick_file(
23 &self,
24 rt: &Runtime,
25 update_tx: &mpsc::UnboundedSender<UiUpdate>,
26 target: FilePickTarget,
27 title: &str,
28 default_dir: &str,
29 );
30}
31
32pub struct Engine {
33 pub model: AppModel,
34 pub client: Arc<UaClient>,
35 pub rt: Runtime,
36 pub update_tx: mpsc::UnboundedSender<UiUpdate>,
37}
38
39impl Engine {
40 pub fn new(
41 rt: Runtime,
42 log_rx: mpsc::UnboundedReceiver<UiUpdate>,
43 ) -> (Self, mpsc::UnboundedReceiver<UiUpdate>) {
44 let (update_tx, update_rx) = mpsc::unbounded_channel();
45 forward_logs(log_rx, update_tx.clone());
46 let engine = Self {
47 model: AppModel::default(),
48 client: Arc::new(UaClient::new()),
49 rt,
50 update_tx,
51 };
52 (engine, update_rx)
53 }
54
55 pub fn apply_update<C: FrontendCtx>(&mut self, ctx: &C, update: UiUpdate) {
56 match update {
57 UiUpdate::ConnectStarted => self.model.connection = ConnectionState::Connecting,
58 UiUpdate::ConnectFinished(Ok(())) => {
59 self.model.connection = ConnectionState::Connected;
60 self.model.record_successful_connection();
61 tracing::info!("connected to {}", self.model.endpoint_url);
62 let saved = self
63 .model
64 .last_selection_paths
65 .get(&self.model.endpoint_url)
66 .cloned();
67 match saved {
68 Some(path) if !path.is_empty() => {
69 tracing::info!(
70 "restoring previous selection ({} ancestors)",
71 path.len()
72 );
73 self.spawn_restore_selection(ctx, path);
74 }
75 _ => {
76 let root = self.model.root_node.clone();
77 self.ensure_expanded(ctx, root);
78 }
79 }
80 }
81 UiUpdate::ConnectFinished(Err(e)) => {
82 self.model.connection = ConnectionState::Disconnected;
83 tracing::error!("connect failed: {e}");
84 }
85 UiUpdate::DisconnectStarted => self.model.connection = ConnectionState::Disconnecting,
86 UiUpdate::DisconnectFinished => {
87 self.model.connection = ConnectionState::Disconnected;
88 self.model.reset_session_state();
89 tracing::info!("disconnected");
90 }
91 UiUpdate::ChildrenLoaded { parent, children } => {
92 self.model.tree.loading.remove(&parent);
93 match children {
94 Ok(c) => {
95 self.model.tree.children.insert(parent.clone(), c);
96 self.model.tree.expanded.insert(parent);
97 }
98 Err(e) => tracing::error!("browse {parent} failed: {e}"),
99 }
100 }
101 UiUpdate::SummaryLoaded { node, summary } => {
102 if self.model.selected.as_ref() == Some(&node) {
103 match summary {
104 Ok(s) => self.model.node_summary = Some(s),
105 Err(e) => tracing::error!("read summary {node} failed: {e}"),
106 }
107 }
108 }
109 UiUpdate::ReferencesLoaded { node, refs } => {
110 if self.model.selected.as_ref() == Some(&node) {
111 self.model.references_loading = false;
112 match refs {
113 Ok(rs) => self.model.references = Some(rs),
114 Err(e) => tracing::error!("browse refs {node} failed: {e}"),
115 }
116 }
117 }
118 UiUpdate::SelectionPathResolved { url, path } => {
119 self.model.last_selection_paths.insert(url, path);
120 }
121 UiUpdate::RestoreSelection(node) => {
122 self.model.selected = Some(node.clone());
123 self.spawn_node_summary(ctx, node.clone());
124 if self.model.active_tab == DetailTab::References {
125 self.spawn_browse_references(ctx, node);
126 }
127 }
128 UiUpdate::PathReady { node, path } => match path {
129 Ok(p) => {
130 ctx.set_clipboard(&p);
131 tracing::info!("copied path: {p}");
132 }
133 Err(e) => tracing::error!("path for {node} failed: {e}"),
134 },
135 UiUpdate::CertPathPicked(p) => self.model.auth_cert_path = p,
136 UiUpdate::KeyPathPicked(p) => self.model.auth_key_path = p,
137 UiUpdate::FilePickerClosed => self.model.file_picker_open = false,
138 UiUpdate::EndpointsDiscovered { url, result } => {
139 if url != self.model.endpoint_url {
140 tracing::debug!("dropping endpoints result for stale url {url}");
141 } else {
142 self.model.endpoints_loading = false;
143 match result {
144 Ok(eps) => {
145 tracing::info!("discovered {} endpoint(s)", eps.len());
146 self.model.discovered_endpoints = Some(eps);
147 self.select_first_matching_endpoint();
148 }
149 Err(e) => {
150 tracing::error!("endpoint discovery failed: {e}");
151 self.model.discovered_endpoints = Some(Vec::new());
152 }
153 }
154 }
155 }
156 UiUpdate::MethodSignatureLoaded { node, result } => {
157 if !self.method_call_targets(&node) {
158 return;
159 }
160 match result {
161 Ok(signature) => {
162 let n_inputs = signature.inputs.len();
163 self.model.method_call = Some(MethodCallState::Inputs {
164 node,
165 signature,
166 edited: vec![String::new(); n_inputs],
167 field_errors: vec![None; n_inputs],
168 call_error: None,
169 });
170 }
171 Err(error) => {
172 tracing::error!("read method signature {node} failed: {error}");
173 self.model.method_call =
174 Some(MethodCallState::Failed { node, error });
175 }
176 }
177 }
178 UiUpdate::MethodCallFinished { node, result } => {
179 if !self.method_call_targets(&node) {
180 return;
181 }
182 let Some(MethodCallState::Calling {
183 node, signature, edited,
184 }) = self.model.method_call.take()
185 else {
186 return;
187 };
188 match result {
189 Ok(outcome) => {
190 self.model.method_call = Some(MethodCallState::Result {
191 node,
192 signature,
193 edited,
194 outcome,
195 });
196 }
197 Err(error) => {
198 tracing::error!("call method {node} failed: {error}");
199 let n_inputs = signature.inputs.len();
200 self.model.method_call = Some(MethodCallState::Inputs {
201 node,
202 signature,
203 edited,
204 field_errors: vec![None; n_inputs],
205 call_error: Some(error),
206 });
207 }
208 }
209 }
210 UiUpdate::SubscribeFinished { node, result } => match result {
211 Ok(display_name) => {
212 self.model.subscribing.remove(&node);
213 if !self.model.subscriptions.iter().any(|r| r.node_id == node) {
214 self.model.subscriptions.push(SubscriptionRow {
215 node_id: node,
216 display_name,
217 value: "<pending>".to_string(),
218 status: String::new(),
219 timestamp: None,
220 });
221 }
222 }
223 Err(e) => {
224 self.model.subscribing.remove(&node);
225 tracing::error!("subscribe {node} failed: {e}");
226 }
227 },
228 UiUpdate::UnsubscribeFinished { node, result } => {
229 self.model.subscribing.remove(&node);
230 self.model.subscriptions.retain(|r| r.node_id != node);
231 if let Err(e) = result {
232 tracing::error!("unsubscribe {node} failed: {e}");
233 }
234 }
235 UiUpdate::DataChange {
236 node,
237 value,
238 status,
239 timestamp,
240 } => {
241 if let Some(row) = self
242 .model
243 .subscriptions
244 .iter_mut()
245 .find(|r| r.node_id == node)
246 {
247 row.value = value;
248 row.status = status;
249 row.timestamp = timestamp;
250 }
251 }
252 UiUpdate::AttributeEditTargetLoaded {
253 node,
254 attr_name,
255 result,
256 } => {
257 if !self.attr_edit_targets(&node, &attr_name) {
258 return;
259 }
260 match result {
261 Ok(target) => {
262 let edited = target.current_value.clone();
263 self.model.attr_edit = Some(AttributeEditState::Inputs {
264 node,
265 attr_name,
266 target,
267 edited,
268 field_error: None,
269 write_error: None,
270 });
271 }
272 Err(error) => {
273 tracing::error!(
274 "read write target {node} {attr_name} failed: {error}"
275 );
276 self.model.attr_edit = Some(AttributeEditState::Failed {
277 node,
278 attr_name,
279 error,
280 });
281 }
282 }
283 }
284 UiUpdate::AttributeWriteFinished {
285 node,
286 attr_name,
287 result,
288 } => {
289 if !self.attr_edit_targets(&node, &attr_name) {
290 return;
291 }
292 match result {
293 Ok(()) => {
294 self.model.attr_edit = None;
295 self.spawn_node_summary(ctx, node);
296 }
297 Err(error) => {
298 tracing::error!("write {node} {attr_name} failed: {error}");
299 let Some(AttributeEditState::Writing {
300 node,
301 attr_name,
302 target,
303 edited,
304 }) = self.model.attr_edit.take()
305 else {
306 return;
307 };
308 self.model.attr_edit = Some(AttributeEditState::Inputs {
309 node,
310 attr_name,
311 target,
312 edited,
313 field_error: None,
314 write_error: Some(error),
315 });
316 }
317 }
318 }
319 UiUpdate::Log(line) => self.model.push_log(line),
320 }
321 }
322
323 fn attr_edit_targets(&self, node: &NodeId, attr_name: &str) -> bool {
324 self.model
325 .attr_edit
326 .as_ref()
327 .map(|s| s.node() == node && s.attr_name() == attr_name)
328 .unwrap_or(false)
329 }
330
331 fn method_call_targets(&self, node: &NodeId) -> bool {
332 self.model
333 .method_call
334 .as_ref()
335 .map(|s| s.node() == node)
336 .unwrap_or(false)
337 }
338
339 pub fn dispatch<C: FrontendCtx>(&mut self, ctx: &C, action: UiAction) {
340 match action {
341 UiAction::EndpointEdited(s) => {
342 if s != self.model.endpoint_url {
343 self.model.endpoint_url = s;
344 self.model.discovered_endpoints = None;
345 self.model.selected_endpoint = None;
346 self.model.endpoints_loading = false;
347 self.model.apply_saved_connection_prefs();
348 }
349 }
350 UiAction::TabSelected(t) => {
351 self.model.active_tab = t;
352 if t == DetailTab::References
353 && let Some(node) = self.model.selected.clone()
354 && self.model.references.is_none()
355 && !self.model.references_loading
356 {
357 self.spawn_browse_references(ctx, node);
358 }
359 }
360 UiAction::ConnectClicked => {
361 if self.model.selected_endpoint.is_none() {
362 tracing::info!("no endpoint selected; opening picker");
363 self.open_endpoint_picker(ctx);
364 } else {
365 let ep = self.model.selected_endpoint.as_ref().unwrap();
366 tracing::info!(
367 "connecting with {} / {}",
368 ep.security_policy,
369 ep.security_mode.label()
370 );
371 self.spawn_connect(ctx);
372 }
373 }
374 UiAction::DisconnectClicked => self.spawn_disconnect(ctx),
375 UiAction::NodeToggleExpand(n) => self.toggle_expand(ctx, n),
376 UiAction::NodeSelected(n) => self.select_node(ctx, n),
377 UiAction::ClearSelection => {
378 self.model.selected = None;
379 self.model.node_summary = None;
380 self.model.references = None;
381 self.model.references_loading = false;
382 }
383 UiAction::RefreshClicked => {
384 if let Some(node) = self.model.selected.clone() {
385 self.spawn_node_summary(ctx, node.clone());
386 if self.model.active_tab == DetailTab::References {
387 self.spawn_browse_references(ctx, node);
388 }
389 }
390 }
391 UiAction::OpenEndpointPicker => {
392 self.open_endpoint_picker(ctx);
393 }
394 UiAction::CloseEndpointPicker => {
395 self.model.endpoints_dialog_open = false;
396 }
397 UiAction::ForceRefreshEndpoints => {
398 if !self.model.endpoints_loading {
399 self.spawn_discover_endpoints(ctx);
400 }
401 }
402 UiAction::SelectEndpoint(ep) => {
403 self.model.selected_endpoint = Some(ep);
404 }
405 UiAction::ClearSelectedEndpoint => {
406 self.model.selected_endpoint = None;
407 }
408 UiAction::SetAuthMode(mode) => self.model.auth_mode = mode,
409 UiAction::SetEndpointModeFilter(mode) => {
410 self.model.endpoint_mode_filter = mode;
411 self.select_first_matching_endpoint();
412 }
413 UiAction::AuthUsernameEdited(s) => self.model.auth_username = s,
414 UiAction::AuthPasswordEdited(s) => self.model.auth_password = s,
415 UiAction::AuthCertPathEdited(s) => self.model.auth_cert_path = s,
416 UiAction::AuthKeyPathEdited(s) => self.model.auth_key_path = s,
417 UiAction::PickAuthCertPath => {
418 if !self.model.file_picker_open {
419 self.model.file_picker_open = true;
420 let default_dir = self.model.auth_cert_path.clone();
421 ctx.pick_file(
422 &self.rt,
423 &self.update_tx,
424 FilePickTarget::CertPath,
425 "Pick client certificate",
426 &default_dir,
427 );
428 }
429 }
430 UiAction::PickAuthKeyPath => {
431 if !self.model.file_picker_open {
432 self.model.file_picker_open = true;
433 let default_dir = self.model.auth_key_path.clone();
434 ctx.pick_file(
435 &self.rt,
436 &self.update_tx,
437 FilePickTarget::KeyPath,
438 "Pick private key",
439 &default_dir,
440 );
441 }
442 }
443 UiAction::CopyPath(node) => self.spawn_browse_path(ctx, node),
444 UiAction::CopyNodeId(node) => {
445 let text = node.to_string();
446 ctx.set_clipboard(&text);
447 tracing::info!("copied node id: {text}");
448 }
449 UiAction::CopyNodeValue => {
450 let Some(summary) = self.model.node_summary.as_ref() else {
451 tracing::warn!("no node summary loaded; nothing to copy");
452 return;
453 };
454 match summary.attributes.iter().find(|a| a.name == "Value") {
455 Some(attr) => {
456 let text = render_value_for_clipboard(&attr.value);
457 ctx.set_clipboard(&text);
458 tracing::info!("copied value of {}", summary.node_id);
459 }
460 None => tracing::warn!(
461 "selected node {} has no Value attribute",
462 summary.node_id
463 ),
464 }
465 }
466 UiAction::ConfirmConnect => {
467 if self.model.selected_endpoint.is_some() {
468 self.model.endpoints_dialog_open = false;
469 self.spawn_connect(ctx);
470 } else {
471 tracing::warn!("ConfirmConnect with no endpoint selected");
472 }
473 }
474 UiAction::OpenMethodCall(node) => self.open_method_call(ctx, node),
475 UiAction::CloseMethodCall => {
476 self.model.method_call = None;
477 }
478 UiAction::MethodArgEdited { index, value } => match self.model.method_call.as_mut() {
479 Some(MethodCallState::Inputs { edited, call_error, field_errors, .. }) => {
480 if let Some(slot) = edited.get_mut(index) {
481 *slot = value;
482 *call_error = None;
483 if let Some(err_slot) = field_errors.get_mut(index) {
484 *err_slot = None;
485 }
486 }
487 }
488 Some(MethodCallState::Result { edited, .. }) => {
489 if let Some(slot) = edited.get_mut(index) {
490 *slot = value;
491 }
492 }
493 _ => {}
494 },
495 UiAction::CallMethodConfirmed => self.confirm_method_call(ctx),
496 UiAction::Subscribe(node) => {
497 if self.model.subscribing.insert(node.clone()) {
498 self.spawn_subscribe(ctx, node);
499 }
500 }
501 UiAction::Unsubscribe(node) => {
502 if self.model.subscribing.insert(node.clone()) {
503 self.spawn_unsubscribe(ctx, node);
504 }
505 }
506 UiAction::OpenAttributeEdit { node, attr_name } => {
507 self.open_attribute_edit(ctx, node, attr_name);
508 }
509 UiAction::CloseAttributeEdit => {
510 self.model.attr_edit = None;
511 }
512 UiAction::AttributeValueEdited(s) => {
513 if let Some(AttributeEditState::Inputs {
514 edited,
515 field_error,
516 write_error,
517 ..
518 }) = self.model.attr_edit.as_mut()
519 {
520 *edited = s;
521 *field_error = None;
522 *write_error = None;
523 }
524 }
525 UiAction::ConfirmAttributeEdit => self.confirm_attribute_edit(ctx),
526 }
527 }
528
529 fn open_attribute_edit<C: FrontendCtx>(
530 &mut self,
531 ctx: &C,
532 node: NodeId,
533 attr_name: String,
534 ) {
535 self.model.attr_edit = Some(AttributeEditState::Loading {
536 node: node.clone(),
537 attr_name: attr_name.clone(),
538 });
539 self.spawn_read_write_target(ctx, node, attr_name);
540 }
541
542 fn confirm_attribute_edit<C: FrontendCtx>(&mut self, ctx: &C) {
543 let Some(AttributeEditState::Inputs {
544 node,
545 attr_name,
546 target,
547 edited,
548 ..
549 }) = self.model.attr_edit.as_ref()
550 else {
551 return;
552 };
553 let value = match parse_attribute_value(&target.spec, edited) {
554 Ok(v) => v,
555 Err(e) => {
556 if let Some(AttributeEditState::Inputs { field_error, .. }) =
557 self.model.attr_edit.as_mut()
558 {
559 *field_error = Some(e);
560 }
561 return;
562 }
563 };
564 let node = node.clone();
565 let attr_name = attr_name.clone();
566 let target = target.clone();
567 let edited = edited.clone();
568 self.model.attr_edit = Some(AttributeEditState::Writing {
569 node: node.clone(),
570 attr_name: attr_name.clone(),
571 target,
572 edited,
573 });
574 self.spawn_write_attribute(ctx, node, attr_name, value);
575 }
576
577 fn spawn_read_write_target<C: FrontendCtx>(
578 &self,
579 ctx: &C,
580 node: NodeId,
581 attr_name: String,
582 ) {
583 let client = self.client.clone();
584 let tx = self.update_tx.clone();
585 let ctx = ctx.clone();
586 self.rt.spawn(async move {
587 let result = client
588 .read_write_target(&node, &attr_name)
589 .await
590 .map_err(|e| e.to_string());
591 let _ = tx.send(UiUpdate::AttributeEditTargetLoaded {
592 node,
593 attr_name,
594 result,
595 });
596 ctx.request_repaint();
597 });
598 }
599
600 fn spawn_write_attribute<C: FrontendCtx>(
601 &self,
602 ctx: &C,
603 node: NodeId,
604 attr_name: String,
605 value: opcua::types::Variant,
606 ) {
607 let client = self.client.clone();
608 let tx = self.update_tx.clone();
609 let ctx = ctx.clone();
610 self.rt.spawn(async move {
611 let result = client
612 .write_attribute(&node, &attr_name, value)
613 .await
614 .map_err(|e| e.to_string());
615 let _ = tx.send(UiUpdate::AttributeWriteFinished {
616 node,
617 attr_name,
618 result,
619 });
620 ctx.request_repaint();
621 });
622 }
623
624 fn open_method_call<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
625 self.model.method_call = Some(MethodCallState::Loading { node: node.clone() });
626 self.spawn_method_signature(ctx, node);
627 }
628
629 fn confirm_method_call<C: FrontendCtx>(&mut self, ctx: &C) {
630 let (node, signature, edited) = match self.model.method_call.as_ref() {
631 Some(MethodCallState::Inputs {
632 node, signature, edited, ..
633 })
634 | Some(MethodCallState::Result {
635 node, signature, edited, ..
636 }) => (node.clone(), signature.clone(), edited.clone()),
637 _ => return,
638 };
639
640 let mut variants = Vec::with_capacity(signature.inputs.len());
641 let mut field_errors = vec![None; signature.inputs.len()];
642 let mut any_error = false;
643 for (i, arg) in signature.inputs.iter().enumerate() {
644 let s = edited.get(i).cloned().unwrap_or_default();
645 match parse_variant(&s, &arg.data_type, arg.value_rank) {
646 Ok(v) => variants.push(v),
647 Err(e) => {
648 field_errors[i] = Some(e);
649 any_error = true;
650 }
651 }
652 }
653 if any_error {
654 self.model.method_call = Some(MethodCallState::Inputs {
655 node,
656 signature,
657 edited,
658 field_errors,
659 call_error: None,
660 });
661 return;
662 }
663
664 let parent = signature.parent_object.clone();
665 let method = signature.method_node.clone();
666 self.model.method_call = Some(MethodCallState::Calling {
667 node: node.clone(),
668 signature,
669 edited,
670 });
671 self.spawn_method_call(ctx, parent, method, variants, node);
672 }
673
674 fn spawn_method_signature<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
675 let client = self.client.clone();
676 let tx = self.update_tx.clone();
677 let ctx = ctx.clone();
678 self.rt.spawn(async move {
679 let result = client
680 .read_method_signature(&node)
681 .await
682 .map_err(|e| e.to_string());
683 let _ = tx.send(UiUpdate::MethodSignatureLoaded { node, result });
684 ctx.request_repaint();
685 });
686 }
687
688 fn spawn_method_call<C: FrontendCtx>(
689 &self,
690 ctx: &C,
691 parent: NodeId,
692 method: NodeId,
693 inputs: Vec<opcua::types::Variant>,
694 node: NodeId,
695 ) {
696 let client = self.client.clone();
697 let tx = self.update_tx.clone();
698 let ctx = ctx.clone();
699 self.rt.spawn(async move {
700 let result = client
701 .call_method(&parent, &method, inputs)
702 .await
703 .map_err(|e| e.to_string());
704 let _ = tx.send(UiUpdate::MethodCallFinished { node, result });
705 ctx.request_repaint();
706 });
707 }
708
709 fn toggle_expand<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
710 if self.model.tree.expanded.contains(&node) {
711 self.model.tree.expanded.remove(&node);
712 } else if self.model.tree.children.contains_key(&node) {
713 self.model.tree.expanded.insert(node);
714 } else if !self.model.tree.loading.contains(&node) {
715 self.model.tree.loading.insert(node.clone());
716 self.spawn_browse_children(ctx, node);
717 }
718 }
719
720 fn ensure_expanded<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
721 if self.model.tree.expanded.contains(&node) {
722 return;
723 }
724 if self.model.tree.children.contains_key(&node) {
725 self.model.tree.expanded.insert(node);
726 } else if !self.model.tree.loading.contains(&node) {
727 self.model.tree.loading.insert(node.clone());
728 self.spawn_browse_children(ctx, node);
729 }
730 }
731
732 fn select_node<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
733 self.model.selected = Some(node.clone());
734 self.model.node_summary = None;
735 self.model.references = None;
736 self.spawn_node_summary(ctx, node.clone());
737 if self.model.active_tab == DetailTab::References {
738 self.spawn_browse_references(ctx, node.clone());
739 }
740 self.spawn_resolve_path(ctx, node);
741 }
742
743 fn select_first_matching_endpoint(&mut self) {
744 if let Some(eps) = self.model.discovered_endpoints.as_ref() {
745 let mut filtered: Vec<&EndpointInfo> = eps
746 .iter()
747 .filter(|e| e.security_mode == self.model.endpoint_mode_filter)
748 .collect();
749 filtered.sort_by(|a, b| b.security_level.cmp(&a.security_level));
750 self.model.selected_endpoint = filtered.first().map(|&e| e.clone());
751 }
752 }
753
754 fn open_endpoint_picker<C: FrontendCtx>(&mut self, ctx: &C) {
755 self.model.endpoints_dialog_open = true;
756 if self.model.discovered_endpoints.is_none() && !self.model.endpoints_loading {
757 self.spawn_discover_endpoints(ctx);
758 }
759 }
760
761 fn spawn_resolve_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
762 let client = self.client.clone();
763 let tx = self.update_tx.clone();
764 let url = self.model.endpoint_url.clone();
765 let ctx = ctx.clone();
766 self.rt.spawn(async move {
767 match client.node_path(&node).await {
768 Ok(path) => {
769 let _ = tx.send(UiUpdate::SelectionPathResolved { url, path });
770 ctx.request_repaint();
771 }
772 Err(e) => tracing::debug!("node_path for {node} failed: {e}"),
773 }
774 });
775 }
776
777 pub fn navigate_to_textual_path<C: FrontendCtx>(&self, ctx: &C, path: String) {
778 let client = self.client.clone();
779 let tx = self.update_tx.clone();
780 let ctx = ctx.clone();
781 self.rt.spawn(async move {
782 let target = match client.resolve_browse_path(&path).await {
783 Ok(t) => t,
784 Err(e) => {
785 tracing::warn!("resolve path '{path}' failed: {e}");
786 return;
787 }
788 };
789 let chain = match client.node_path(&target).await {
790 Ok(c) => c,
791 Err(e) => {
792 tracing::warn!("node_path for '{path}' failed: {e}");
793 return;
794 }
795 };
796 if chain.is_empty() {
797 return;
798 }
799 let final_target = chain.last().cloned().unwrap();
800 for parent in chain.iter().take(chain.len() - 1) {
801 match client.browse_children(parent).await {
802 Ok(children) => {
803 let _ = tx.send(UiUpdate::ChildrenLoaded {
804 parent: parent.clone(),
805 children: Ok(children),
806 });
807 }
808 Err(e) => {
809 tracing::warn!("navigate: browse_children({parent}) failed: {e}");
810 ctx.request_repaint();
811 return;
812 }
813 }
814 }
815 let _ = tx.send(UiUpdate::RestoreSelection(final_target));
816 ctx.request_repaint();
817 });
818 }
819
820 fn spawn_restore_selection<C: FrontendCtx>(&self, ctx: &C, path: Vec<NodeId>) {
821 let client = self.client.clone();
822 let tx = self.update_tx.clone();
823 let ctx = ctx.clone();
824 self.rt.spawn(async move {
825 if path.is_empty() {
826 return;
827 }
828 let target = path.last().cloned().unwrap();
829 for parent in path.iter().take(path.len() - 1) {
830 match client.browse_children(parent).await {
831 Ok(children) => {
832 let _ = tx.send(UiUpdate::ChildrenLoaded {
833 parent: parent.clone(),
834 children: Ok(children),
835 });
836 }
837 Err(e) => {
838 tracing::warn!("restore: browse_children({parent}) failed: {e}");
839 ctx.request_repaint();
840 return;
841 }
842 }
843 }
844 let _ = tx.send(UiUpdate::RestoreSelection(target));
845 ctx.request_repaint();
846 });
847 }
848
849 fn spawn_connect<C: FrontendCtx>(&mut self, ctx: &C) {
850 let client = self.client.clone();
851 let tx = self.update_tx.clone();
852 let url = self.model.endpoint_url.clone();
853 let endpoint = self.model.selected_endpoint.clone();
854 let auth = AuthSpec {
855 mode: self.model.auth_mode,
856 username: self.model.auth_username.clone(),
857 password: self.model.auth_password.clone(),
858 cert_path: self.model.auth_cert_path.clone(),
859 key_path: self.model.auth_key_path.clone(),
860 };
861 let ctx = ctx.clone();
862 let _ = tx.send(UiUpdate::ConnectStarted);
863 self.rt.spawn(async move {
864 let r = client
865 .connect(&url, endpoint.as_ref(), &auth)
866 .await
867 .map_err(|e| e.to_string());
868 let _ = tx.send(UiUpdate::ConnectFinished(r));
869 ctx.request_repaint();
870 });
871 }
872
873 fn spawn_discover_endpoints<C: FrontendCtx>(&mut self, ctx: &C) {
874 self.model.endpoints_loading = true;
875 self.model.discovered_endpoints = None;
876 let client = self.client.clone();
877 let tx = self.update_tx.clone();
878 let url = self.model.endpoint_url.clone();
879 let ctx = ctx.clone();
880 self.rt.spawn(async move {
881 let r = client
882 .discover_endpoints(&url)
883 .await
884 .map_err(|e| e.to_string());
885 let _ = tx.send(UiUpdate::EndpointsDiscovered { url, result: r });
886 ctx.request_repaint();
887 });
888 }
889
890 fn spawn_disconnect<C: FrontendCtx>(&self, ctx: &C) {
891 let client = self.client.clone();
892 let tx = self.update_tx.clone();
893 let ctx = ctx.clone();
894 let _ = tx.send(UiUpdate::DisconnectStarted);
895 self.rt.spawn(async move {
896 if let Err(e) = client.disconnect().await {
897 tracing::warn!("disconnect: {e}");
898 }
899 let _ = tx.send(UiUpdate::DisconnectFinished);
900 ctx.request_repaint();
901 });
902 }
903
904 fn spawn_browse_children<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
905 let client = self.client.clone();
906 let tx = self.update_tx.clone();
907 let ctx = ctx.clone();
908 self.rt.spawn(async move {
909 let r = client.browse_children(&node).await.map_err(|e| e.to_string());
910 let _ = tx.send(UiUpdate::ChildrenLoaded {
911 parent: node,
912 children: r,
913 });
914 ctx.request_repaint();
915 });
916 }
917
918 fn spawn_node_summary<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
919 let client = self.client.clone();
920 let tx = self.update_tx.clone();
921 let ctx = ctx.clone();
922 self.rt.spawn(async move {
923 let r = client
924 .read_node_summary(&node)
925 .await
926 .map_err(|e| e.to_string());
927 let _ = tx.send(UiUpdate::SummaryLoaded { node, summary: r });
928 ctx.request_repaint();
929 });
930 }
931
932 fn spawn_browse_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
933 let client = self.client.clone();
934 let tx = self.update_tx.clone();
935 let ctx = ctx.clone();
936 self.rt.spawn(async move {
937 let r = client.browse_path(&node).await.map_err(|e| e.to_string());
938 let _ = tx.send(UiUpdate::PathReady { node, path: r });
939 ctx.request_repaint();
940 });
941 }
942
943 fn spawn_subscribe<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
944 let client = self.client.clone();
945 let tx = self.update_tx.clone();
946 let ctx = ctx.clone();
947 let data_tx = self.update_tx.clone();
948 self.rt.spawn(async move {
949 let result = client
950 .subscribe(node.clone(), data_tx)
951 .await
952 .map_err(|e| e.to_string());
953 let _ = tx.send(UiUpdate::SubscribeFinished { node, result });
954 ctx.request_repaint();
955 });
956 }
957
958 fn spawn_unsubscribe<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
959 let client = self.client.clone();
960 let tx = self.update_tx.clone();
961 let ctx = ctx.clone();
962 self.rt.spawn(async move {
963 let result = client.unsubscribe(&node).await.map_err(|e| e.to_string());
964 let _ = tx.send(UiUpdate::UnsubscribeFinished { node, result });
965 ctx.request_repaint();
966 });
967 }
968
969 fn spawn_browse_references<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
970 self.model.references_loading = true;
971 let client = self.client.clone();
972 let tx = self.update_tx.clone();
973 let ctx = ctx.clone();
974 self.rt.spawn(async move {
975 let r = client
976 .browse_references(&node)
977 .await
978 .map_err(|e| e.to_string());
979 let _ = tx.send(UiUpdate::ReferencesLoaded { node, refs: r });
980 ctx.request_repaint();
981 });
982 }
983}
984
985fn render_value_for_clipboard(value: &ValueTree) -> String {
986 use std::fmt::Write as _;
987 fn render(v: &ValueTree, indent: usize, out: &mut String) {
988 let pad = " ".repeat(indent);
989 match v {
990 ValueTree::Null => {
991 let _ = write!(out, "{pad}<null>");
992 }
993 ValueTree::Leaf(s) => {
994 let _ = write!(out, "{pad}{s}");
995 }
996 ValueTree::Array(items) => {
997 for (i, item) in items.iter().enumerate() {
998 if i > 0 {
999 out.push('\n');
1000 }
1001 let _ = write!(out, "{pad}[{i}]");
1002 out.push('\n');
1003 render(item, indent + 1, out);
1004 }
1005 }
1006 ValueTree::Object(fields) => {
1007 for (i, (k, val)) in fields.iter().enumerate() {
1008 if i > 0 {
1009 out.push('\n');
1010 }
1011 let _ = write!(out, "{pad}{k}:");
1012 out.push('\n');
1013 render(val, indent + 1, out);
1014 }
1015 }
1016 }
1017 }
1018 let mut out = String::new();
1019 render(value, 0, &mut out);
1020 out
1021}
1022
1023fn forward_logs(
1024 mut log_rx: mpsc::UnboundedReceiver<UiUpdate>,
1025 update_tx: mpsc::UnboundedSender<UiUpdate>,
1026) {
1027 std::thread::spawn(move || {
1028 while let Some(msg) = log_rx.blocking_recv() {
1029 if update_tx.send(msg).is_err() {
1030 break;
1031 }
1032 }
1033 });
1034}