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