Skip to main content

ua_client/
engine.rs

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}