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}