diff --git a/CHANGELOG.md b/CHANGELOG.md index 06fa6bde..52126ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,36 @@ its heading and collects entries. The date and the link go on with the tag. Felix Stubner. Upgrading from an earlier version leaves one entry, under the new name. +- **CSV files saved by the desktop app start with a UTF-8 byte-order mark.** + The app wrote plain UTF-8, and Excel on Windows reads a CSV that lacks the + mark in its older code page, so a device called "Felix’s iPhone" would show + as "Felix’s iPhone". Both CSV exports now begin with the mark, which is how + Excel is told a file is UTF-8. The command line's CSV is meant for pipes and + has none, and JSON exports are unchanged. A script that reads these files as + plain UTF-8 will see the mark in front of the first header and needs + `utf-8-sig` or the equivalent. + +- **The desktop app's content security policy is tighter.** It no longer + allows images from data or blob URLs or from the asset protocol, and it + forbids `` elements, form submission and plugin objects. The app uses + none of them. + +- **The update notice says why an install cannot update itself.** Installs + from Scoop, the AUR or a .deb got the same "Update available" notice as + everyone else, and its link opens the release page, which invites a manual + download over a copy the package manager owns. The notice now carries the + reason, for example "Installed with Scoop, so update it there". + +- **The update dialog says the app closes to finish.** On Windows the app + exits as the installer starts, and nothing said so if the installer was then + declined or failed. The dialog now says NetsCLI closes to finish and should + reopen by itself, and to open it again if it does not. + +- **The Packet Capture screen says no published installer includes it.** It + told people to use a PCAP-enabled desktop build, which reads as a download + that does not exist. It now says the published installers are built without + packet capture and that it needs a build made from source. + ### Fixed - **Text from the network could reorder or hide itself.** The cleaning that @@ -223,6 +253,88 @@ its heading and collects entries. The date and the link go on with the tag. `--version` and gives up after 3 seconds. A program still running at that point was left behind. It is now stopped. +- **Console windows no longer open when the desktop app looks up names or + traces a route on Windows.** Discover and Sweep run `ping -a` for every host + that answers, Trace Route runs `tracert`, and Settings runs + `netscli --version` for each candidate it finds. The app has no console of + its own, so each of those opened one. With Windows Terminal as the default + terminal that is a window titled with the tool's path, open for as long as + the tool runs. Called from a program with no console, a name lookup opened + that window, and with the fix it opened none. The ARP commands already + started this way. + +- **A route trace no longer fails on a Windows set to another language.** + On a Windows in a language with accented letters, such as German or French, + `tracert` can print a byte that is not valid UTF-8, and reading its output + as UTF-8 ended the whole trace with "trace stdout read failed", hops already + printed included. The output is now decoded leniently, so the odd character + shows as a replacement mark and the trace completes. + +- **A trace refuses a target that the trace tool would read as an option.** + The target goes to `tracert`, `traceroute` or `tracepath` as a plain + argument, so `netscli trace -- -d` handed the tool a flag. A target that is + empty or starts with `-` or `/` now gets an error before anything runs. A + host name or an address never starts with either, and Windows' `tracert` + reads a leading `/` as an option too. + +- **File dialogs in the desktop app no longer freeze the window.** Open + Result Bundle, Choose Folder and the Save dialog for exports waited for the + dialog on the thread that draws the window, which the dialog library says + not to do. On Windows, Open Result Bundle left the window unable to answer + anything for as long as its dialog was open, and Windows marked it as not + responding after about five seconds. The others wait the same way. They now + wait off that thread, and the window keeps answering. + +- **A stalled update download no longer traps the update dialog.** The dialog + cannot be closed while an update downloads, and nothing limited how long a + download could take, so one that stalled kept the dialog open until the app + was quit. The update check now gives up after 30 seconds and the download + after 10 minutes, and the dialog then shows its message and a link to the + release page. + +- **The desktop app announces failed runs and progress to screen readers.** A + failed run's message and the progress bar were silent at the default + settings, because the only live regions were the toasts, which are off by + default. The error message is now an alert, and a hidden status line says + when a run starts, at each quarter of the way, and when it finishes with its + summary or is stopped. + +- **Escape no longer stops a scan when it only closes a dialog or menu.** + Closing the About dialog, the update dialog or a context menu with Escape + also cancelled the scan running underneath it. Escape now closes what is + open and leaves the scan alone. + +- **A damaged save-settings file no longer stops every export.** One + unreadable `gui-save-settings.json` made every export and capture fail + until the file was deleted by hand, and the Settings controls could not + repair it. It now reads as the defaults, and the next change writes a good + file over it. The file is written through a temporary one, so an + interruption cannot leave it half written. + +- **A damaged result file no longer crashes the desktop window, and the error + screen is usable.** A result bundle whose entries lacked fields passed the + checks and then broke the table while it was drawn, which ended the + session. Such a file is now refused with a message. The error screen was + black on near-black on a light theme and gave no way to move or close the + window. It is now readable and has the window buttons. + +- **The command the desktop app copies can no longer carry a shell command + from a result file.** The command strip, its copy button and the History + entry are built from the tab's form values, and opening a result file fills + those from the file. A host such as `1.1.1.1 && calc` was one click from the + clipboard as a working command. Values with spaces or shell syntax are now + quoted as one argument, and a value no quoting can make safe (one with a + `$`, a backtick, a double quote, a `%` or a `!` in it) is left out. A + capture filter containing a double quote is now left out too, where it was + escaped before. + +- **Smaller accessibility fixes in the desktop app.** Error toasts stay until + they are dismissed instead of leaving after under two seconds. The tab + close buttons no longer add a Tab stop each to the tab strip. Workspace + search tells a screen reader which result is current. The update dialog is + no longer read out again at every step of a download. The tab spinner stops + turning when the system asks for reduced motion. + - **MCP clients built on the official SDK can connect.** Every reply the MCP server sent carried both a result and an error, one of them empty, which the protocol doesn't allow. The official TypeScript SDK drops a reply like diff --git a/apps/netscli-gui/src-tauri/capabilities/main.json b/apps/netscli-gui/src-tauri/capabilities/main.json index 1bbacb6c..18d37e60 100644 --- a/apps/netscli-gui/src-tauri/capabilities/main.json +++ b/apps/netscli-gui/src-tauri/capabilities/main.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main", - "description": "Capabilities granted to the main window. Tauri 2 is deny-by-default per permission, and `core:window:default` only includes read-only operations (is-maximized, inner-size, etc.). Without explicit grants for the mutating ops, the close/minimize/maximize buttons in TitleBar.tsx silently fail because the app uses `decorations: false` and a custom React title bar instead of the OS-native window chrome.", + "description": "Capabilities granted to the main window. Tauri 2 is deny-by-default per permission, and `core:window:default` only includes read-only operations (is-maximized, inner-size, etc.). Without explicit grants for the mutating ops, the close/minimize/maximize buttons in AppFrame.tsx silently fail because the app uses `decorations: false` and a custom React title bar instead of the OS-native window chrome.", "windows": ["main"], "permissions": [ "core:default", diff --git a/apps/netscli-gui/src-tauri/src/commands/files/artifacts.rs b/apps/netscli-gui/src-tauri/src/commands/files/artifacts.rs index 12f405b2..16d6084f 100644 --- a/apps/netscli-gui/src-tauri/src/commands/files/artifacts.rs +++ b/apps/netscli-gui/src-tauri/src/commands/files/artifacts.rs @@ -98,3 +98,60 @@ fn is_in_allowed_save_root(path: &Path) -> Result { Ok(false) } + +// What the renderer may ask the OS to open or reveal. These stop at the checks +// that need nothing but the file and the registry; the save-folder check reads +// the user's real settings and creates the default folder, which a test +// should not do. +#[cfg(test)] +mod tests { + use super::*; + + fn temp_file(tag: &str, name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "netscli-artifact-test-{}-{tag}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, "x").unwrap(); + path + } + + #[test] + fn only_the_kinds_the_app_writes_can_be_opened() { + for name in ["a.pcap", "a.json", "a.csv", "a.txt", "A.CSV"] { + assert!(has_supported_artifact_extension(Path::new(name)), "{name}"); + } + for name in ["a.exe", "a.ps1", "a.lnk", "a.html", "a"] { + assert!(!has_supported_artifact_extension(Path::new(name)), "{name}"); + } + } + + #[test] + fn an_empty_missing_or_wrong_kind_of_path_is_refused() { + let registry = ArtifactRegistry::default(); + assert!(validate_saved_artifact_path(" ", ®istry).is_err()); + assert!(validate_saved_artifact_path("definitely/not/here.csv", ®istry).is_err()); + + let exe = temp_file("exe", "tool.exe"); + let err = validate_saved_artifact_path(exe.to_str().unwrap(), ®istry).unwrap_err(); + assert!(err.contains("not supported"), "{err}"); + + let dir = exe.parent().unwrap(); + let err = validate_saved_artifact_path(dir.to_str().unwrap(), ®istry).unwrap_err(); + assert!(err.contains("must be a file"), "{err}"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_file_this_session_wrote_is_accepted() { + let registry = ArtifactRegistry::default(); + let file = temp_file("registered", "scan.csv"); + registry.register(&file).unwrap(); + + let accepted = validate_saved_artifact_path(file.to_str().unwrap(), ®istry).unwrap(); + assert_eq!(accepted, std::fs::canonicalize(&file).unwrap()); + std::fs::remove_dir_all(file.parent().unwrap()).unwrap(); + } +} diff --git a/apps/netscli-gui/src-tauri/src/commands/files/dialog.rs b/apps/netscli-gui/src-tauri/src/commands/files/dialog.rs new file mode 100644 index 00000000..f4b2dbf2 --- /dev/null +++ b/apps/netscli-gui/src-tauri/src/commands/files/dialog.rs @@ -0,0 +1,76 @@ +use tokio::sync::oneshot; + +/// Show a native file dialog from an `async` command and wait for its answer. +/// +/// `show` is one of the dialog plugin's callback calls (`pick_file`, +/// `pick_folder`, `save_file`). It puts the dialog on the main thread and +/// reports from a thread of its own, so nothing here blocks the main thread, +/// and nothing blocks a runtime worker either while the user decides. +/// +/// This is why the commands that open a dialog are `async`. A plain `fn` +/// command runs on the main thread, and the plugin says its `blocking_*` calls +/// "should *NOT* be used when running on the main thread". The window stops +/// repainting for as long as the dialog is open, and on macOS the dialog is +/// driven by the main run loop, which a main thread waiting on the dialog +/// cannot also run. +/// +/// `None` is a cancelled dialog, or an app that began closing before the dialog +/// was answered. +pub(super) async fn ask( + show: impl FnOnce(Box) + Send>), +) -> Option { + let (answer, answered) = oneshot::channel(); + show(Box::new(move |chosen| { + let _ = answer.send(chosen); + })); + answered.await.ok().flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + /// The wait must leave the thread it runs on free. + /// + /// The plugin's own `blocking_*` calls park the calling thread on a channel, + /// and when these commands were plain `fn`s that thread was the main one. A + /// current-thread runtime makes the difference visible: a second task has + /// to get time while `ask` waits for a dialog answered from another thread, + /// the way the plugin answers. + #[tokio::test(flavor = "current_thread")] + async fn waiting_for_a_dialog_leaves_the_thread_free() { + let ticks = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&ticks); + tokio::spawn(async move { + loop { + counter.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(2)).await; + } + }); + + let chosen = ask(|done| { + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + done(Some("report.json")); + }); + }) + .await; + + assert_eq!(chosen, Some("report.json")); + assert!( + ticks.load(Ordering::SeqCst) > 1, + "nothing else ran while the dialog was open" + ); + } + + #[tokio::test] + async fn a_dialog_that_is_never_answered_reads_as_cancelled() { + // The app closing with a dialog open drops the callback unanswered. + // The plugin's blocking calls unwrap that and panic. + let chosen: Option = ask(drop).await; + assert_eq!(chosen, None); + } +} diff --git a/apps/netscli-gui/src-tauri/src/commands/files/export.rs b/apps/netscli-gui/src-tauri/src/commands/files/export.rs index 64133cf2..fb8ab2ca 100644 --- a/apps/netscli-gui/src-tauri/src/commands/files/export.rs +++ b/apps/netscli-gui/src-tauri/src/commands/files/export.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use tauri::State; use tauri_plugin_dialog::DialogExt; +use super::dialog; use super::preferences::{ default_save_directory, format_byte_limit, preferred_save_directory, read_file_save_preferences, timestamp_millis, @@ -11,8 +12,9 @@ use crate::state::ArtifactRegistry; const MAX_RESULT_BUNDLE_BYTES: u64 = 25 * 1024 * 1024; +// The commands that can open a dialog are `async`; see `dialog::ask`. #[tauri::command] -pub(crate) fn export_text_file( +pub(crate) async fn export_text_file( app: tauri::AppHandle, artifact_registry: State<'_, ArtifactRegistry>, filename: String, @@ -27,7 +29,7 @@ pub(crate) fn export_text_file( ); } - let path = resolve_export_path(&app, &filename)?; + let path = resolve_export_path(&app, &filename).await?; if let Some(parent) = path .parent() @@ -43,7 +45,7 @@ pub(crate) fn export_text_file( } #[tauri::command] -pub(crate) fn save_result_bundle( +pub(crate) async fn save_result_bundle( app: tauri::AppHandle, artifact_registry: State<'_, ArtifactRegistry>, contents: String, @@ -55,17 +57,18 @@ pub(crate) fn save_result_bundle( )); } let filename = format!("netscli-result-{}.netscli-result.json", timestamp_millis()); - export_text_file(app, artifact_registry, filename, contents, None) + export_text_file(app, artifact_registry, filename, contents, None).await } #[tauri::command] -pub(crate) fn open_result_bundle(app: tauri::AppHandle) -> Result { - let selected = app +pub(crate) async fn open_result_bundle(app: tauri::AppHandle) -> Result { + let picker = app .dialog() .file() .set_title("Open NetsCLI Result") - .add_filter("NetsCLI Result", &["json"]) - .blocking_pick_file() + .add_filter("NetsCLI Result", &["json"]); + let selected = dialog::ask(|done| picker.pick_file(done)) + .await .ok_or_else(|| "Open result cancelled".to_string())?; let path = selected .into_path() @@ -95,7 +98,7 @@ pub(crate) fn ensure_file_size_limit( Ok(()) } -fn resolve_export_path(app: &tauri::AppHandle, filename: &str) -> Result { +async fn resolve_export_path(app: &tauri::AppHandle, filename: &str) -> Result { if let Some(mut path) = std::env::var_os("NETSCLI_EXPORT_DIR").map(PathBuf::from) { path.push(filename); return Ok(path); @@ -113,16 +116,16 @@ fn resolve_export_path(app: &tauri::AppHandle, filename: &str) -> Result, ) -> Result { - let mut dialog = app + let mut picker = app .dialog() .file() .set_title("Save NetsCLI Export") @@ -130,14 +133,14 @@ fn ask_export_path( .set_can_create_directories(true); if let Some((name, extensions)) = export_filter(filename) { - dialog = dialog.add_filter(name, &extensions); + picker = picker.add_filter(name, &extensions); } if let Some(directory) = preferred_save_directory(default_directory)? { - dialog = dialog.set_directory(directory); + picker = picker.set_directory(directory); } - let selected = dialog - .blocking_save_file() + let selected = dialog::ask(|done| picker.save_file(done)) + .await .ok_or_else(|| "Export cancelled".to_string())?; let path = selected @@ -199,3 +202,66 @@ fn sanitize_export_filename(filename: &str) -> Result { Ok(cleaned) } + +// What the renderer is and is not allowed to write. The webview supplies the +// filename, so these are what keeps an export inside the save folder. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_names_the_app_generates_pass_unchanged() { + for name in [ + "netscli-scan-1790000000000.csv", + "netscli-result-1790000000000.netscli-result.json", + ] { + assert_eq!(sanitize_export_filename(name).unwrap(), name); + } + } + + #[test] + fn a_filename_cannot_carry_a_path_out_of_the_folder() { + for name in [ + "../../outside.json", + "..\\..\\outside.json", + "C:\\Windows\\win.ini", + "/etc/passwd", + "sub/dir/file.txt", + "name:stream.txt", + ] { + let cleaned = sanitize_export_filename(name).unwrap(); + assert!( + !cleaned.contains(['/', '\\', ':']), + "{name:?} became {cleaned:?}" + ); + assert_eq!( + PathBuf::from(&cleaned).components().count(), + 1, + "{cleaned:?}" + ); + } + } + + #[test] + fn a_filename_with_nothing_usable_in_it_is_refused() { + for name in ["", " ", ".", "..", "---", "///"] { + assert!(sanitize_export_filename(name).is_err(), "{name:?}"); + } + } + + #[test] + fn the_save_folder_is_created_and_must_be_a_folder() { + let base = std::env::temp_dir().join(format!("netscli-export-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + + let made = export_path_in_directory(base.join("nested"), "a.csv").unwrap(); + assert_eq!(made, base.join("nested").join("a.csv")); + assert!(base.join("nested").is_dir()); + + let file = base.join("not-a-folder"); + std::fs::write(&file, "x").unwrap(); + assert!(export_path_in_directory(file, "a.csv").is_err()); + + std::fs::remove_dir_all(&base).unwrap(); + } +} diff --git a/apps/netscli-gui/src-tauri/src/commands/files/mod.rs b/apps/netscli-gui/src-tauri/src/commands/files/mod.rs index bbb019f8..819287f3 100644 --- a/apps/netscli-gui/src-tauri/src/commands/files/mod.rs +++ b/apps/netscli-gui/src-tauri/src/commands/files/mod.rs @@ -1,4 +1,5 @@ mod artifacts; +mod dialog; mod export; mod pcap_path; mod preferences; diff --git a/apps/netscli-gui/src-tauri/src/commands/files/preferences.rs b/apps/netscli-gui/src-tauri/src/commands/files/preferences.rs index f449f2d0..c3e82ac0 100644 --- a/apps/netscli-gui/src-tauri/src/commands/files/preferences.rs +++ b/apps/netscli-gui/src-tauri/src/commands/files/preferences.rs @@ -1,9 +1,11 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use tauri_plugin_dialog::DialogExt; +use super::dialog; + const SAVE_SETTINGS_FILE: &str = "gui-save-settings.json"; const LEGACY_CAPTURE_SETTINGS_FILE: &str = "gui-capture-settings.json"; @@ -29,15 +31,16 @@ pub(crate) fn set_file_save_ask_each_time( } #[tauri::command] -pub(crate) fn choose_file_save_default_directory( +pub(crate) async fn choose_file_save_default_directory( app: tauri::AppHandle, ) -> Result { - let selected = app + let picker = app .dialog() .file() .set_title("Choose NetsCLI Save Folder") - .set_can_create_directories(true) - .blocking_pick_folder() + .set_can_create_directories(true); + let selected = dialog::ask(|done| picker.pick_folder(done)) + .await .ok_or_else(|| "Folder selection cancelled".to_string())?; let path = selected @@ -90,21 +93,47 @@ pub(super) fn read_file_save_preferences() -> Result FileSavePreferences { + serde_json::from_str(text).unwrap_or_else(|error| { + // Only a debug build has anywhere to show this; the release app has no + // console. + eprintln!("netscli-gui: {file} could not be read, using the defaults: {error}"); + FileSavePreferences::default() + }) } fn write_file_save_preferences(prefs: &FileSavePreferences) -> Result<(), String> { let path = save_settings_path()?; let text = serde_json::to_string_pretty(prefs) .map_err(|e| format!("Failed to serialize save settings: {e}"))?; - std::fs::write(&path, text).map_err(|e| format!("Failed to write save settings: {e}")) + replace_file(&path, &text).map_err(|e| format!("Failed to write save settings: {e}")) +} + +/// Write beside the file and rename over it, so an interruption leaves the old +/// settings in place rather than a truncated file. +fn replace_file(path: &Path, text: &str) -> std::io::Result<()> { + let temp = path.with_extension("json.tmp"); + std::fs::write(&temp, text)?; + std::fs::rename(&temp, path).inspect_err(|_| { + let _ = std::fs::remove_file(&temp); + }) } pub(super) fn preferred_save_directory(directory: Option<&str>) -> Result, String> { @@ -143,3 +172,51 @@ pub(super) fn format_byte_limit(bytes: u64) -> String { format!("{bytes} bytes") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_damaged_settings_file_reads_as_the_defaults() { + // Truncated by a crash mid-write, empty, and the wrong shape. + for text in [ + "", + "{\"ask_each_time\": tr", + "not json", + "{\"ask_each_time\": \"yes\"}", + ] { + let prefs = parse_preferences(text, SAVE_SETTINGS_FILE); + assert!(!prefs.ask_each_time, "{text:?}"); + assert_eq!(prefs.default_directory, None, "{text:?}"); + } + } + + #[test] + fn a_good_settings_file_is_read_as_written() { + let prefs = parse_preferences( + r#"{"ask_each_time": true, "default_directory": "D:\\Scans"}"#, + SAVE_SETTINGS_FILE, + ); + assert!(prefs.ask_each_time); + assert_eq!(prefs.default_directory.as_deref(), Some("D:\\Scans")); + } + + #[test] + fn replacing_a_file_swaps_its_contents_and_leaves_no_temporary_behind() { + let dir = std::env::temp_dir().join(format!("netscli-prefs-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("gui-save-settings.json"); + + replace_file(&path, "old").unwrap(); + replace_file(&path, "new").unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + let names: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(names, ["gui-save-settings.json"]); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/apps/netscli-gui/src-tauri/src/commands/mcp.rs b/apps/netscli-gui/src-tauri/src/commands/mcp.rs index fbd1f5eb..cbaa8b93 100644 --- a/apps/netscli-gui/src-tauri/src/commands/mcp.rs +++ b/apps/netscli-gui/src-tauri/src/commands/mcp.rs @@ -41,6 +41,11 @@ const BIN_NAME: &str = if cfg!(windows) { /// A hung probe would freeze the panel with no way out. const PROBE_TIMEOUT: Duration = Duration::from_secs(3); +/// Process creation flag that stops a console program opening a window. The +/// same one the core sets for `arp`, `ping` and `tracert`. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CliDetection { @@ -132,6 +137,10 @@ async fn probe_version(path: &Path) -> Option { .arg("--version") .stdin(std::process::Stdio::null()) .kill_on_drop(true); + // `netscli` is a console program and this app has no console, so each + // candidate would otherwise open its own window while Settings is opening. + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); let output = timeout(PROBE_TIMEOUT, command.output()).await.ok()?.ok()?; if !output.status.success() { diff --git a/apps/netscli-gui/src-tauri/src/commands/operations/capture.rs b/apps/netscli-gui/src-tauri/src/commands/operations/capture.rs index c11ebf84..8e7ca138 100644 --- a/apps/netscli-gui/src-tauri/src/commands/operations/capture.rs +++ b/apps/netscli-gui/src-tauri/src/commands/operations/capture.rs @@ -8,6 +8,20 @@ use crate::state::{ArtifactRegistry, OperationManager}; const MAX_GUI_PCAP_IMPORT_BYTES: u64 = 512 * 1024 * 1024; +/// What opening a capture reads when the Packets field is empty, and the most +/// it reads whatever that field says: the field's placeholder and its maximum +/// in registry.ts. Without a limit the core summarises every packet up to ten +/// million and hands them all back as one JSON value, while the progress text +/// says "up to 1000". +const DEFAULT_GUI_PCAP_IMPORT_PACKETS: usize = 1000; +const MAX_GUI_PCAP_IMPORT_PACKETS: usize = 100_000; + +fn import_packet_limit(requested: Option) -> usize { + requested + .unwrap_or(DEFAULT_GUI_PCAP_IMPORT_PACKETS) + .min(MAX_GUI_PCAP_IMPORT_PACKETS) +} + #[derive(serde::Serialize)] pub(crate) struct PcapCapability { compiled: bool, @@ -89,8 +103,28 @@ pub(crate) async fn open_pcap_file( let ops = Ops::default(); let parsed = ops - .parse_pcap_file(path.display().to_string(), max_packets) + .parse_pcap_file( + path.display().to_string(), + Some(import_packet_limit(max_packets)), + ) .map_err(|e| e.to_string())?; artifact_registry.register(&path)?; serde_json::to_value(parsed).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_packets_field_does_not_mean_every_packet() { + assert_eq!(import_packet_limit(None), 1000); + } + + #[test] + fn a_requested_limit_is_kept_up_to_the_fields_maximum() { + assert_eq!(import_packet_limit(Some(50)), 50); + assert_eq!(import_packet_limit(Some(100_000)), 100_000); + assert_eq!(import_packet_limit(Some(10_000_000)), 100_000); + } +} diff --git a/apps/netscli-gui/src-tauri/src/commands/operations/mod.rs b/apps/netscli-gui/src-tauri/src/commands/operations/mod.rs index 2663b53b..5668052c 100644 --- a/apps/netscli-gui/src-tauri/src/commands/operations/mod.rs +++ b/apps/netscli-gui/src-tauri/src/commands/operations/mod.rs @@ -219,6 +219,13 @@ impl Drop for RemoveOnDrop { // `Drop` cannot await, so the removal is spawned. Nothing waits on // the entry being gone -- op ids are unique per run, so a late // removal cannot strand a later operation. + // + // DNS Lookup with record type ALL is the exception: it reuses its + // id for ten calls in a row. A removal that ran after the next call + // had registered would take that call's entry, and Stop would find + // nothing to abort for it. This removal is spawned as one call ends + // and the next has to cross the IPC boundary to arrive, so that order + // is not expected, and the cost would be one lookup that Stop missed. let registry = Arc::clone(&self.registry); let op_id = std::mem::take(&mut self.op_id); tauri::async_runtime::spawn(async move { diff --git a/apps/netscli-gui/src-tauri/src/state.rs b/apps/netscli-gui/src-tauri/src/state.rs index b80ea3f8..c6e15f42 100644 --- a/apps/netscli-gui/src-tauri/src/state.rs +++ b/apps/netscli-gui/src-tauri/src/state.rs @@ -59,8 +59,10 @@ impl OperationManager { /// sender either way. /// /// `register` also aborts a task when an op id is re-used, which would - /// look the same. Op ids are UUIDs minted per run (`generateId('op')`), so - /// that path is unreachable in practice. + /// look the same. Op ids are UUIDs minted per run (`generateId('op')`), + /// with one exception: DNS Lookup with record type ALL makes ten calls, + /// one after another, under its run's id. Each is finished before the next + /// is sent, so none replaces a live entry. pub(crate) async fn is_registered(&self, op_id: &str) -> bool { self.tasks.lock().await.contains_key(op_id) } diff --git a/apps/netscli-gui/src-tauri/tauri.conf.json b/apps/netscli-gui/src-tauri/tauri.conf.json index 9512d0ae..40cb2f60 100644 --- a/apps/netscli-gui/src-tauri/tauri.conf.json +++ b/apps/netscli-gui/src-tauri/tauri.conf.json @@ -25,9 +25,12 @@ "csp": { "default-src": "'self' asset: http://asset.localhost", "connect-src": "ipc: http://ipc.localhost https://api.github.com", - "img-src": "'self' asset: http://asset.localhost blob: data:", + "img-src": "'self'", "style-src": "'self'", - "script-src": "'self'" + "script-src": "'self'", + "base-uri": "'none'", + "form-action": "'none'", + "object-src": "'none'" } } }, diff --git a/apps/netscli-gui/src/components/primitives/ErrorBoundary.test.tsx b/apps/netscli-gui/src/components/primitives/ErrorBoundary.test.tsx new file mode 100644 index 00000000..fffa2e20 --- /dev/null +++ b/apps/netscli-gui/src/components/primitives/ErrorBoundary.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// +// The screen a crashed window turns into. Nothing exercised it before, and it +// is the one screen that cannot lean on the rest of the app: no menu, no +// preferences, and none of the styles `.container` supplies. + +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ErrorBoundary } from './ErrorBoundary'; + +function Boom(): never { + throw new Error('service.addresses.join is not a function'); +} + +beforeEach(() => { + // React reports the error it caught; the test does not need the noise. + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('ErrorBoundary', () => { + it('says what happened and how to recover', () => { + render( + + + , + ); + expect(screen.getByRole('alert').textContent).toContain('Something went wrong'); + expect(screen.getByText('service.addresses.join is not a function')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy(); + }); + + it('keeps a way to move and close a window that has no title bar', () => { + render( + + + , + ); + for (const name of ['Minimize', 'Maximize', 'Close']) { + expect(screen.getByRole('button', { name })).toBeTruthy(); + } + }); + + it('is drawn inside .container, which is where the colour tokens are defined', () => { + // Outside it the text colour comes from the OS theme on a page that is + // always dark: black on near-black for anyone on a light theme. jsdom has + // no stylesheet, so the structure is what can be checked. + render( + + + , + ); + expect(screen.getByTestId('error-boundary').closest('.container')).not.toBeNull(); + }); +}); diff --git a/apps/netscli-gui/src/components/primitives/ErrorBoundary.tsx b/apps/netscli-gui/src/components/primitives/ErrorBoundary.tsx index ec8c458c..82da2028 100644 --- a/apps/netscli-gui/src/components/primitives/ErrorBoundary.tsx +++ b/apps/netscli-gui/src/components/primitives/ErrorBoundary.tsx @@ -1,5 +1,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { CrashScreen } from '../shell/CrashScreen'; + /** * Last line of defence against a render throw blanking the window. * @@ -40,18 +42,6 @@ export class ErrorBoundary extends Component { const { error } = this.state; if (!error) return this.props.children; - return ( -
-

Something went wrong

-

- The window stopped rendering and could not recover. Reloading starts a fresh session; any - results still open will be lost. -

-
{error.message}
- -
- ); + return ; } } diff --git a/apps/netscli-gui/src/components/results/JsonPreview.tsx b/apps/netscli-gui/src/components/results/JsonPreview.tsx index c20b9fc1..9107b7a2 100644 --- a/apps/netscli-gui/src/components/results/JsonPreview.tsx +++ b/apps/netscli-gui/src/components/results/JsonPreview.tsx @@ -1,9 +1,12 @@ -import type { ReactNode } from 'react'; +import { useMemo, type ReactNode } from 'react'; export function JsonPreview({ value }: { value: string }) { + // Tokenising a large result is not cheap, and this re-rendered with the whole + // app on every selection change and status poll while `value` stayed the same. + const tokens = useMemo(() => highlightJson(value), [value]); return (
-      {highlightJson(value)}
+      {tokens}
     
); } diff --git a/apps/netscli-gui/src/components/results/PcapUnavailableState.tsx b/apps/netscli-gui/src/components/results/PcapUnavailableState.tsx index 073ee03a..1866a0cd 100644 --- a/apps/netscli-gui/src/components/results/PcapUnavailableState.tsx +++ b/apps/netscli-gui/src/components/results/PcapUnavailableState.tsx @@ -11,7 +11,7 @@ export function PcapUnavailableState({ capability }: { capability: PcapCapabilit const buildWithoutPcap = !capability.compiled || capability.message?.includes("built without feature 'pcap'"); const title = buildWithoutPcap ? 'Packet Capture is not included in this build' : 'Packet Capture needs a capture driver'; const body = buildWithoutPcap - ? 'Use a PCAP-enabled NetsCLI Desktop build. On Windows, Npcap is also required before captures can run.' + ? 'The published desktop installers are built without packet capture, so this needs a build made from source with the pcap feature. The setup docs list the options. On Windows, Npcap is also required before captures can run.' : 'Install Npcap on Windows, or libpcap on Linux/macOS, then restart NetsCLI and open Packet Capture again.'; return ( diff --git a/apps/netscli-gui/src/components/shell/CrashScreen.tsx b/apps/netscli-gui/src/components/shell/CrashScreen.tsx new file mode 100644 index 00000000..b97e51b9 --- /dev/null +++ b/apps/netscli-gui/src/components/shell/CrashScreen.tsx @@ -0,0 +1,39 @@ +import { appWindowAction } from '../../services/appWindow'; +import { AppFrame } from './AppFrame'; +import { handleAppFrameMouseDown } from './appFrameDrag'; + +/** + * What replaces the window when a render throws. See `ErrorBoundary`. + * + * Two things the first version of this lacked, both from the same cause: it + * renders where nothing of the app is mounted. + * + * The colour tokens are defined on `.container` alone, so outside it the text + * took the operating system's colour on a page that is dark whatever the + * theme, which is black on near-black for anyone on a light theme. And the + * window has no native title bar (`decorations: false`), so with the app's own + * frame gone there was nothing to move, minimise or close it with. + */ +export function CrashScreen({ error }: { error: Error }) { + return ( +
+ void appWindowAction(action)} + > + {null} + +
+

Something went wrong

+

+ The window stopped rendering and could not recover. Reloading starts a fresh session; any + results still open will be lost. +

+
{error.message}
+ +
+
+ ); +} diff --git a/apps/netscli-gui/src/components/shell/RunAnnouncer.test.tsx b/apps/netscli-gui/src/components/shell/RunAnnouncer.test.tsx new file mode 100644 index 00000000..ab7679a2 --- /dev/null +++ b/apps/netscli-gui/src/components/shell/RunAnnouncer.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +// +// What a screen reader hears while a run is going. Only the live region's text +// is checked, because that is the whole of what reaches the user: if the region +// does not change, nothing is spoken. + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { createTab } from '../../tools/registry'; +import type { OperationProgressState, WorkspaceTab } from '../../tools/types'; +import { RunAnnouncer } from './RunAnnouncer'; + +const scan = createTab('scan'); + +function running(progress?: Partial): WorkspaceTab { + return { + ...scan, + busy: true, + progress: { kind: 'scan', completed: 0, total: 0, found: 0, ...progress }, + }; +} + +function spoken() { + return screen.getByRole('status').textContent; +} + +describe('RunAnnouncer', () => { + it('is a polite live region that exists before anything is said', () => { + render(); + const region = screen.getByRole('status'); + expect(region.getAttribute('aria-live')).toBe('polite'); + expect(spoken()).toBe(''); + }); + + it('announces the start and the end of a run, with its summary', () => { + const { rerender } = render(); + + rerender(); + expect(spoken()).toBe('Port Scan started'); + + const result = { + kind: 'scan', + data: [ + { port: 22, open: true }, + { port: 80, open: false }, + ], + } as never; + rerender(); + expect(spoken()).toBe('Port Scan complete. 2 results - 1 open'); + }); + + it('says a stopped run stopped, and leaves a failed run to the error strip', () => { + const { rerender } = render(); + rerender(); + rerender(); + expect(spoken()).toBe('Port Scan stopped'); + + rerender(); + rerender(); + // Still the start message: no second voice for the same failure. + expect(spoken()).toBe('Port Scan started'); + }); + + it('reports progress a quarter at a time, not on every event', () => { + const { rerender } = render(); + rerender(); + + const heard: string[] = []; + for (let completed = 10; completed <= 900; completed += 10) { + rerender(); + const text = spoken() ?? ''; + if (heard[heard.length - 1] !== text) heard.push(text); + } + expect(heard).toEqual([ + 'Port Scan started', + 'Port Scan, 25% complete', + 'Port Scan, 50% complete', + 'Port Scan, 75% complete', + ]); + }); + + it('keeps quiet when the person switches to another tab', () => { + const other = createTab('ping'); + const { rerender } = render(); + // The tab shown now is mid-run, which is not something that just happened. + rerender(); + expect(spoken()).toBe(''); + }); + + it('does not turn a trace into a percentage', () => { + const trace = createTab('trace'); + const { rerender } = render(); + rerender(); + rerender( + , + ); + expect(spoken()).toBe('Trace Route started'); + }); +}); diff --git a/apps/netscli-gui/src/components/shell/RunAnnouncer.tsx b/apps/netscli-gui/src/components/shell/RunAnnouncer.tsx new file mode 100644 index 00000000..77c61015 --- /dev/null +++ b/apps/netscli-gui/src/components/shell/RunAnnouncer.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from 'react'; + +import { resultSummary } from '../../tools/presentation'; +import { TOOL_CONFIG } from '../../tools/registry'; +import type { WorkspaceTab } from '../../tools/types'; + +/** + * Says what the active tab's run is doing to people who cannot see it. + * + * Until this existed the main flow was silent to a screen reader at stock + * settings. The progress bar has no live text, and a result arriving in the + * table is only a completion signal if you can see the table. The toasts that + * are live regions are opt-in and off by default, so they could not be the + * answer. A failed run is not announced here: the error strip is a `role=alert` + * and says it itself. + * + * Deliberately sparse. A run says when it starts, at each quarter of the way + * through (at most three times for each step it has), and when it ends. A + * progress event every 100 ms read aloud would be unusable. + * + * It only follows the tab being looked at. A run finishing in a background tab + * is what the "operation toasts" setting is for. + */ +export function RunAnnouncer({ tab }: { tab: WorkspaceTab | undefined }) { + const [message, setMessage] = useState(''); + const before = useRef(null); + + useEffect(() => { + const next = announcement(before.current, tab); + before.current = next.snapshot; + if (next.message) setMessage(next.message); + }, [tab]); + + // Rendered even when empty. A screen reader only announces changes inside a + // live region that already existed, which is why ToastHost does the same. + return ( +
+ {message} +
+ ); +} + +interface RunSnapshot { + tabId: string; + busy: boolean; + /** How many quarters of the current step are done, 0 to 3. */ + quarter: number; +} + +/** What to say now, given how the tab looked the last time this was asked. */ +function announcement( + before: RunSnapshot | null, + tab: WorkspaceTab | undefined, +): { snapshot: RunSnapshot | null; message: string | null } { + if (!tab) return { snapshot: null, message: null }; + const snapshot = snapshotOf(tab); + // The first sight of a tab, or a switch to another one. Nothing has + // happened that the person did not just do themselves. + if (!before || before.tabId !== tab.id) return { snapshot, message: null }; + + const label = TOOL_CONFIG[tab.kind].label; + if (!before.busy && tab.busy) return { snapshot, message: `${label} started` }; + + if (before.busy && !tab.busy) { + // The error strip announces its own message. + if (tab.error) return { snapshot, message: null }; + // Stop clears the result of every tool except trace, which keeps the hops + // it had printed, so a stopped trace reads as complete with those hops. + return { + snapshot, + message: tab.result ? `${label} complete. ${resultSummary(tab.result)}` : `${label} stopped`, + }; + } + + // Only a step forward is news. A run with two steps (discover probes, then + // resolves names) restarts its count for the second, which is not one. + if (tab.busy && snapshot.quarter > before.quarter) { + return { snapshot, message: `${label}, ${snapshot.quarter * 25}% complete` }; + } + return { snapshot, message: null }; +} + +function snapshotOf(tab: WorkspaceTab): RunSnapshot { + const total = tab.progress?.total ?? 0; + const completed = tab.progress?.completed ?? 0; + // A trace reports the hop it has reached out of the maximum, and almost + // always ends well short of it, so a percentage would say something false. + const counted = tab.busy && total > 0 && tab.kind !== 'trace'; + return { + tabId: tab.id, + busy: tab.busy, + quarter: counted ? Math.min(3, Math.floor((completed / total) * 4)) : 0, + }; +} diff --git a/apps/netscli-gui/src/components/shell/TabStrip.a11y.test.tsx b/apps/netscli-gui/src/components/shell/TabStrip.a11y.test.tsx index a342c161..f8fe1db1 100644 --- a/apps/netscli-gui/src/components/shell/TabStrip.a11y.test.tsx +++ b/apps/netscli-gui/src/components/shell/TabStrip.a11y.test.tsx @@ -56,6 +56,17 @@ describe('tab strip keyboard access', () => { expect(tabIndexes).toEqual(['-1', '0', '-1']); }); + // The close buttons sit inside the tabs. A button is a tab stop by default, + // so each one added a stop of its own to a strip meant to be a single one, + // and an ARIA tab's children are presentational, so a screen reader had no + // button to offer there anyway. Delete closes the focused tab by keyboard. + it('keeps the close buttons out of the tab order', () => { + renderStrip(1); + const closeButtons = Array.from(document.querySelectorAll('button.tab-close')); + expect(closeButtons).toHaveLength(3); + expect(closeButtons.map((el) => el.getAttribute('tabindex'))).toEqual(['-1', '-1', '-1']); + }); + it('moves to the next tab on ArrowRight and wraps at the end', () => { const { tabs, onSelectTab } = renderStrip(2); const active = screen.getAllByRole('tab')[2]; diff --git a/apps/netscli-gui/src/components/shell/TabStrip.tsx b/apps/netscli-gui/src/components/shell/TabStrip.tsx index 70ab7c95..b9d0cc79 100644 --- a/apps/netscli-gui/src/components/shell/TabStrip.tsx +++ b/apps/netscli-gui/src/components/shell/TabStrip.tsx @@ -203,6 +203,7 @@ export function TabStrip({ aria-label={`Close ${identity.label} tab`} data-tooltip="Close Tab" data-tooltip-placement="bottom" + tabIndex={-1} onClick={(event) => { event.stopPropagation(); onCloseTab(tab.id); diff --git a/apps/netscli-gui/src/components/shell/ToastHost.test.tsx b/apps/netscli-gui/src/components/shell/ToastHost.test.tsx index 5faaae1d..23b61908 100644 --- a/apps/netscli-gui/src/components/shell/ToastHost.test.tsx +++ b/apps/netscli-gui/src/components/shell/ToastHost.test.tsx @@ -86,4 +86,16 @@ describe('ToastHost update toast', () => { ); expect(screen.getByTestId('update-dialog')).toBeTruthy(); }); + + // The toast region is polite and atomic, so it reads out everything inside + // it whenever anything inside it changes. The dialog's progress text changes + // many times a second during a download, and sat in there, which meant the + // whole dialog was read out again for each change. + it('keeps the dialog out of the atomic live region', () => { + renderToast( + true, + updates({ dialogOpen: true, pendingUpdate: { version: '0.4.0', body: 'notes' } }), + ); + expect(screen.getByTestId('update-dialog').closest('[aria-atomic="true"]')).toBeNull(); + }); }); diff --git a/apps/netscli-gui/src/components/shell/ToastHost.tsx b/apps/netscli-gui/src/components/shell/ToastHost.tsx index 85018805..1746aae3 100644 --- a/apps/netscli-gui/src/components/shell/ToastHost.tsx +++ b/apps/netscli-gui/src/components/shell/ToastHost.tsx @@ -32,15 +32,23 @@ export function ToastHost({ appVersion, dismissToast, setActiveTabId, toast, upd // // `.toast` is `position: absolute`, and a static wrapper does not create a // containing block, so this does not move it. + // + // The update dialog is not inside it. The region is atomic, so it reads + // out everything in it whenever anything in it changes, and the dialog's + // own status line changes with every step of a download. That read the + // whole dialog out again each time. The dialog has its own live region for + // the progress text. return ( -
- {toast && } + <> +
+ {toast && } +
{updates.dialogOpen && update && ( updates.skipVersion(update.version)} /> )} -
+ ); } diff --git a/apps/netscli-gui/src/components/shell/Toolbar.test.tsx b/apps/netscli-gui/src/components/shell/Toolbar.test.tsx new file mode 100644 index 00000000..53c41896 --- /dev/null +++ b/apps/netscli-gui/src/components/shell/Toolbar.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +// +// The filter hints are built from every row of the result. They only change +// when the result does, but the toolbar re-renders with the whole app, so +// building them in the render body redid that work for each keystroke in the +// filter box, each selection change and each status poll. + +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { createTab } from '../../tools/registry'; +import type { WorkspaceTab } from '../../tools/types'; + +vi.mock('../../tools/presentation', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, filterHintsFor: vi.fn(actual.filterHintsFor) }; +}); + +const { filterHintsFor } = await import('../../tools/presentation'); +const { Toolbar } = await import('./Toolbar'); + +function toolbar(tab: WorkspaceTab, filterText: string) { + return ( + + ); +} + +describe('Toolbar filter hints', () => { + it('are built once per result, not once per render', () => { + const tab = { ...createTab('scan'), result: { kind: 'scan', data: [] } } as WorkspaceTab; + vi.mocked(filterHintsFor).mockClear(); + + const { rerender } = render(toolbar(tab, '')); + rerender(toolbar(tab, 'status:open')); + rerender(toolbar({ ...tab, selectedIndex: 3 }, 'status:open 22')); + + expect(filterHintsFor).toHaveBeenCalledTimes(1); + }); + + it('are built again for a new result', () => { + const tab = { ...createTab('scan'), result: { kind: 'scan', data: [] } } as WorkspaceTab; + vi.mocked(filterHintsFor).mockClear(); + + const { rerender } = render(toolbar(tab, '')); + rerender(toolbar({ ...tab, result: { kind: 'scan', data: [] } } as WorkspaceTab, '')); + + expect(filterHintsFor).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/netscli-gui/src/components/shell/Toolbar.tsx b/apps/netscli-gui/src/components/shell/Toolbar.tsx index 54b95c71..a91ad69e 100644 --- a/apps/netscli-gui/src/components/shell/Toolbar.tsx +++ b/apps/netscli-gui/src/components/shell/Toolbar.tsx @@ -1,5 +1,5 @@ import { ChevronDown, Download, FileSpreadsheet, Filter, Square, X } from 'lucide-react'; -import { useRef, type RefObject } from 'react'; +import { useMemo, useRef, type RefObject } from 'react'; import { TOOL_CONFIG } from '../../tools/registry'; import { filterHintsFor } from '../../tools/presentation'; @@ -39,7 +39,15 @@ export function Toolbar({ }: ToolbarProps) { const filterMenuOpen = openMenu === 'advanced-filter'; const kind = activeTab?.kind ?? 'scan'; - const filterHints = filterHintsFor(activeTab); + // Builds every row of the result and scans each column for its values, and + // depends on nothing but the tab's kind and result. Done in the render body + // it was redone for every progress event, selection change and status poll. + const hintsKind = activeTab?.kind; + const hintsResult = activeTab?.result ?? null; + const filterHints = useMemo( + () => filterHintsFor(hintsKind ? { kind: hintsKind, result: hintsResult } : undefined), + [hintsKind, hintsResult], + ); const runLabel = activeTab ? runLabelFor(kind) : 'Start'; const filterButtonRef = useRef(null); const filterPanelRef = useRef(null); diff --git a/apps/netscli-gui/src/components/shell/UpdateDialog.tsx b/apps/netscli-gui/src/components/shell/UpdateDialog.tsx index 341c807c..1362ed20 100644 --- a/apps/netscli-gui/src/components/shell/UpdateDialog.tsx +++ b/apps/netscli-gui/src/components/shell/UpdateDialog.tsx @@ -76,7 +76,8 @@ export function UpdateDialog({

NetsCLI {version} is available

You have {currentVersion}. The update is checked against NetsCLI's signing key - before it installs, and the app restarts to finish. + before it installs. NetsCLI closes to finish and should reopen by itself. If it does + not, open it again.

-
+
{matches.length === 0 ? ( No workspace matches. ) : ( @@ -155,6 +171,7 @@ export function WorkspaceSearchDialog({