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;
9use crate::messages::{UiAction, UiUpdate};
10use crate::model::{AppModel, ConnectionState, DetailTab};
11use crate::types::{AuthSpec, EndpointInfo, 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::Log(line) => self.model.push_log(line),
157        }
158    }
159
160    pub fn dispatch<C: FrontendCtx>(&mut self, ctx: &C, action: UiAction) {
161        match action {
162            UiAction::EndpointEdited(s) => {
163                if s != self.model.endpoint_url {
164                    self.model.endpoint_url = s;
165                    self.model.discovered_endpoints = None;
166                    self.model.selected_endpoint = None;
167                    self.model.endpoints_loading = false;
168                }
169            }
170            UiAction::TabSelected(t) => {
171                self.model.active_tab = t;
172                if t == DetailTab::References
173                    && let Some(node) = self.model.selected.clone()
174                    && self.model.references.is_none()
175                    && !self.model.references_loading
176                {
177                    self.spawn_browse_references(ctx, node);
178                }
179            }
180            UiAction::ConnectClicked => {
181                if self.model.selected_endpoint.is_none() {
182                    tracing::info!("no endpoint selected; opening picker");
183                    self.open_endpoint_picker(ctx);
184                } else {
185                    let ep = self.model.selected_endpoint.as_ref().unwrap();
186                    tracing::info!(
187                        "connecting with {} / {}",
188                        ep.security_policy,
189                        ep.security_mode.label()
190                    );
191                    self.spawn_connect(ctx);
192                }
193            }
194            UiAction::DisconnectClicked => self.spawn_disconnect(ctx),
195            UiAction::NodeToggleExpand(n) => self.toggle_expand(ctx, n),
196            UiAction::NodeSelected(n) => self.select_node(ctx, n),
197            UiAction::ClearSelection => {
198                self.model.selected = None;
199                self.model.node_summary = None;
200                self.model.references = None;
201                self.model.references_loading = false;
202            }
203            UiAction::RefreshClicked => {
204                if let Some(node) = self.model.selected.clone() {
205                    self.spawn_node_summary(ctx, node.clone());
206                    if self.model.active_tab == DetailTab::References {
207                        self.spawn_browse_references(ctx, node);
208                    }
209                }
210            }
211            UiAction::OpenEndpointPicker => {
212                self.open_endpoint_picker(ctx);
213            }
214            UiAction::CloseEndpointPicker => {
215                self.model.endpoints_dialog_open = false;
216            }
217            UiAction::ForceRefreshEndpoints => {
218                if !self.model.endpoints_loading {
219                    self.spawn_discover_endpoints(ctx);
220                }
221            }
222            UiAction::SelectEndpoint(ep) => {
223                self.model.selected_endpoint = Some(ep);
224            }
225            UiAction::ClearSelectedEndpoint => {
226                self.model.selected_endpoint = None;
227            }
228            UiAction::SetAuthMode(mode) => self.model.auth_mode = mode,
229            UiAction::SetEndpointModeFilter(mode) => {
230                self.model.endpoint_mode_filter = mode;
231                self.select_first_matching_endpoint();
232            }
233            UiAction::AuthUsernameEdited(s) => self.model.auth_username = s,
234            UiAction::AuthPasswordEdited(s) => self.model.auth_password = s,
235            UiAction::AuthCertPathEdited(s) => self.model.auth_cert_path = s,
236            UiAction::AuthKeyPathEdited(s) => self.model.auth_key_path = s,
237            UiAction::PickAuthCertPath => {
238                if !self.model.file_picker_open {
239                    self.model.file_picker_open = true;
240                    let default_dir = self.model.auth_cert_path.clone();
241                    ctx.pick_file(
242                        &self.rt,
243                        &self.update_tx,
244                        FilePickTarget::CertPath,
245                        "Pick client certificate",
246                        &default_dir,
247                    );
248                }
249            }
250            UiAction::PickAuthKeyPath => {
251                if !self.model.file_picker_open {
252                    self.model.file_picker_open = true;
253                    let default_dir = self.model.auth_key_path.clone();
254                    ctx.pick_file(
255                        &self.rt,
256                        &self.update_tx,
257                        FilePickTarget::KeyPath,
258                        "Pick private key",
259                        &default_dir,
260                    );
261                }
262            }
263            UiAction::CopyPath(node) => self.spawn_browse_path(ctx, node),
264            UiAction::CopyNodeId(node) => {
265                let text = node.to_string();
266                ctx.set_clipboard(&text);
267                tracing::info!("copied node id: {text}");
268            }
269            UiAction::CopyNodeValue => {
270                let Some(summary) = self.model.node_summary.as_ref() else {
271                    tracing::warn!("no node summary loaded; nothing to copy");
272                    return;
273                };
274                match summary.attributes.iter().find(|a| a.name == "Value") {
275                    Some(attr) => {
276                        let text = render_value_for_clipboard(&attr.value);
277                        ctx.set_clipboard(&text);
278                        tracing::info!("copied value of {}", summary.node_id);
279                    }
280                    None => tracing::warn!(
281                        "selected node {} has no Value attribute",
282                        summary.node_id
283                    ),
284                }
285            }
286            UiAction::ConfirmConnect => {
287                if self.model.selected_endpoint.is_some() {
288                    self.model.endpoints_dialog_open = false;
289                    self.spawn_connect(ctx);
290                } else {
291                    tracing::warn!("ConfirmConnect with no endpoint selected");
292                }
293            }
294        }
295    }
296
297    fn toggle_expand<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
298        if self.model.tree.expanded.contains(&node) {
299            self.model.tree.expanded.remove(&node);
300        } else if self.model.tree.children.contains_key(&node) {
301            self.model.tree.expanded.insert(node);
302        } else if !self.model.tree.loading.contains(&node) {
303            self.model.tree.loading.insert(node.clone());
304            self.spawn_browse_children(ctx, node);
305        }
306    }
307
308    fn ensure_expanded<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
309        if self.model.tree.expanded.contains(&node) {
310            return;
311        }
312        if self.model.tree.children.contains_key(&node) {
313            self.model.tree.expanded.insert(node);
314        } else if !self.model.tree.loading.contains(&node) {
315            self.model.tree.loading.insert(node.clone());
316            self.spawn_browse_children(ctx, node);
317        }
318    }
319
320    fn select_node<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
321        self.model.selected = Some(node.clone());
322        self.model.node_summary = None;
323        self.model.references = None;
324        self.spawn_node_summary(ctx, node.clone());
325        if self.model.active_tab == DetailTab::References {
326            self.spawn_browse_references(ctx, node.clone());
327        }
328        self.spawn_resolve_path(ctx, node);
329    }
330
331    fn select_first_matching_endpoint(&mut self) {
332        if let Some(eps) = self.model.discovered_endpoints.as_ref() {
333            let mut filtered: Vec<&EndpointInfo> = eps
334                .iter()
335                .filter(|e| e.security_mode == self.model.endpoint_mode_filter)
336                .collect();
337            filtered.sort_by(|a, b| b.security_level.cmp(&a.security_level));
338            self.model.selected_endpoint = filtered.first().map(|&e| e.clone());
339        }
340    }
341
342    fn open_endpoint_picker<C: FrontendCtx>(&mut self, ctx: &C) {
343        self.model.endpoints_dialog_open = true;
344        if self.model.discovered_endpoints.is_none() && !self.model.endpoints_loading {
345            self.spawn_discover_endpoints(ctx);
346        }
347    }
348
349    fn spawn_resolve_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
350        let client = self.client.clone();
351        let tx = self.update_tx.clone();
352        let url = self.model.endpoint_url.clone();
353        let ctx = ctx.clone();
354        self.rt.spawn(async move {
355            match client.node_path(&node).await {
356                Ok(path) => {
357                    let _ = tx.send(UiUpdate::SelectionPathResolved { url, path });
358                    ctx.request_repaint();
359                }
360                Err(e) => tracing::debug!("node_path for {node} failed: {e}"),
361            }
362        });
363    }
364
365    pub fn navigate_to_textual_path<C: FrontendCtx>(&self, ctx: &C, path: String) {
366        let client = self.client.clone();
367        let tx = self.update_tx.clone();
368        let ctx = ctx.clone();
369        self.rt.spawn(async move {
370            let target = match client.resolve_browse_path(&path).await {
371                Ok(t) => t,
372                Err(e) => {
373                    tracing::warn!("resolve path '{path}' failed: {e}");
374                    return;
375                }
376            };
377            let chain = match client.node_path(&target).await {
378                Ok(c) => c,
379                Err(e) => {
380                    tracing::warn!("node_path for '{path}' failed: {e}");
381                    return;
382                }
383            };
384            if chain.is_empty() {
385                return;
386            }
387            let final_target = chain.last().cloned().unwrap();
388            for parent in chain.iter().take(chain.len() - 1) {
389                match client.browse_children(parent).await {
390                    Ok(children) => {
391                        let _ = tx.send(UiUpdate::ChildrenLoaded {
392                            parent: parent.clone(),
393                            children: Ok(children),
394                        });
395                    }
396                    Err(e) => {
397                        tracing::warn!("navigate: browse_children({parent}) failed: {e}");
398                        ctx.request_repaint();
399                        return;
400                    }
401                }
402            }
403            let _ = tx.send(UiUpdate::RestoreSelection(final_target));
404            ctx.request_repaint();
405        });
406    }
407
408    fn spawn_restore_selection<C: FrontendCtx>(&self, ctx: &C, path: Vec<NodeId>) {
409        let client = self.client.clone();
410        let tx = self.update_tx.clone();
411        let ctx = ctx.clone();
412        self.rt.spawn(async move {
413            if path.is_empty() {
414                return;
415            }
416            let target = path.last().cloned().unwrap();
417            for parent in path.iter().take(path.len() - 1) {
418                match client.browse_children(parent).await {
419                    Ok(children) => {
420                        let _ = tx.send(UiUpdate::ChildrenLoaded {
421                            parent: parent.clone(),
422                            children: Ok(children),
423                        });
424                    }
425                    Err(e) => {
426                        tracing::warn!("restore: browse_children({parent}) failed: {e}");
427                        ctx.request_repaint();
428                        return;
429                    }
430                }
431            }
432            let _ = tx.send(UiUpdate::RestoreSelection(target));
433            ctx.request_repaint();
434        });
435    }
436
437    fn spawn_connect<C: FrontendCtx>(&mut self, ctx: &C) {
438        let client = self.client.clone();
439        let tx = self.update_tx.clone();
440        let url = self.model.endpoint_url.clone();
441        let endpoint = self.model.selected_endpoint.clone();
442        let auth = AuthSpec {
443            mode: self.model.auth_mode,
444            username: self.model.auth_username.clone(),
445            password: self.model.auth_password.clone(),
446            cert_path: self.model.auth_cert_path.clone(),
447            key_path: self.model.auth_key_path.clone(),
448        };
449        let ctx = ctx.clone();
450        let _ = tx.send(UiUpdate::ConnectStarted);
451        self.rt.spawn(async move {
452            let r = client
453                .connect(&url, endpoint.as_ref(), &auth)
454                .await
455                .map_err(|e| e.to_string());
456            let _ = tx.send(UiUpdate::ConnectFinished(r));
457            ctx.request_repaint();
458        });
459    }
460
461    fn spawn_discover_endpoints<C: FrontendCtx>(&mut self, ctx: &C) {
462        self.model.endpoints_loading = true;
463        self.model.discovered_endpoints = None;
464        let client = self.client.clone();
465        let tx = self.update_tx.clone();
466        let url = self.model.endpoint_url.clone();
467        let ctx = ctx.clone();
468        self.rt.spawn(async move {
469            let r = client
470                .discover_endpoints(&url)
471                .await
472                .map_err(|e| e.to_string());
473            let _ = tx.send(UiUpdate::EndpointsDiscovered { url, result: r });
474            ctx.request_repaint();
475        });
476    }
477
478    fn spawn_disconnect<C: FrontendCtx>(&self, ctx: &C) {
479        let client = self.client.clone();
480        let tx = self.update_tx.clone();
481        let ctx = ctx.clone();
482        let _ = tx.send(UiUpdate::DisconnectStarted);
483        self.rt.spawn(async move {
484            if let Err(e) = client.disconnect().await {
485                tracing::warn!("disconnect: {e}");
486            }
487            let _ = tx.send(UiUpdate::DisconnectFinished);
488            ctx.request_repaint();
489        });
490    }
491
492    fn spawn_browse_children<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
493        let client = self.client.clone();
494        let tx = self.update_tx.clone();
495        let ctx = ctx.clone();
496        self.rt.spawn(async move {
497            let r = client.browse_children(&node).await.map_err(|e| e.to_string());
498            let _ = tx.send(UiUpdate::ChildrenLoaded {
499                parent: node,
500                children: r,
501            });
502            ctx.request_repaint();
503        });
504    }
505
506    fn spawn_node_summary<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
507        let client = self.client.clone();
508        let tx = self.update_tx.clone();
509        let ctx = ctx.clone();
510        self.rt.spawn(async move {
511            let r = client
512                .read_node_summary(&node)
513                .await
514                .map_err(|e| e.to_string());
515            let _ = tx.send(UiUpdate::SummaryLoaded { node, summary: r });
516            ctx.request_repaint();
517        });
518    }
519
520    fn spawn_browse_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
521        let client = self.client.clone();
522        let tx = self.update_tx.clone();
523        let ctx = ctx.clone();
524        self.rt.spawn(async move {
525            let r = client.browse_path(&node).await.map_err(|e| e.to_string());
526            let _ = tx.send(UiUpdate::PathReady { node, path: r });
527            ctx.request_repaint();
528        });
529    }
530
531    fn spawn_browse_references<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
532        self.model.references_loading = true;
533        let client = self.client.clone();
534        let tx = self.update_tx.clone();
535        let ctx = ctx.clone();
536        self.rt.spawn(async move {
537            let r = client
538                .browse_references(&node)
539                .await
540                .map_err(|e| e.to_string());
541            let _ = tx.send(UiUpdate::ReferencesLoaded { node, refs: r });
542            ctx.request_repaint();
543        });
544    }
545}
546
547fn render_value_for_clipboard(value: &ValueTree) -> String {
548    use std::fmt::Write as _;
549    fn render(v: &ValueTree, indent: usize, out: &mut String) {
550        let pad = "  ".repeat(indent);
551        match v {
552            ValueTree::Null => {
553                let _ = write!(out, "{pad}<null>");
554            }
555            ValueTree::Leaf(s) => {
556                let _ = write!(out, "{pad}{s}");
557            }
558            ValueTree::Array(items) => {
559                for (i, item) in items.iter().enumerate() {
560                    if i > 0 {
561                        out.push('\n');
562                    }
563                    let _ = write!(out, "{pad}[{i}]");
564                    out.push('\n');
565                    render(item, indent + 1, out);
566                }
567            }
568            ValueTree::Object(fields) => {
569                for (i, (k, val)) in fields.iter().enumerate() {
570                    if i > 0 {
571                        out.push('\n');
572                    }
573                    let _ = write!(out, "{pad}{k}:");
574                    out.push('\n');
575                    render(val, indent + 1, out);
576                }
577            }
578        }
579    }
580    let mut out = String::new();
581    render(value, 0, &mut out);
582    out
583}
584
585fn forward_logs(
586    mut log_rx: mpsc::UnboundedReceiver<UiUpdate>,
587    update_tx: mpsc::UnboundedSender<UiUpdate>,
588) {
589    std::thread::spawn(move || {
590        while let Some(msg) = log_rx.blocking_recv() {
591            if update_tx.send(msg).is_err() {
592                break;
593            }
594        }
595    });
596}