Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
582130d
Decode trace output lossily so a localized tracert cannot abort the t…
fstubner Oct 7, 2026
1be6b89
Start ping -a, tracert and the CLI version probe without a console wi…
fstubner Oct 7, 2026
faad25a
Open the file dialogs from async commands so the window keeps painting
fstubner Oct 7, 2026
5b30648
Give the update check and download a timeout so a stall cannot lock t…
fstubner Oct 7, 2026
0473178
Announce failed runs and run progress to screen readers by default
fstubner Oct 7, 2026
b174914
Keep Escape from stopping a run when it only closes a dialog or menu
fstubner Oct 7, 2026
7ac34d8
Tell the user the app closes while an update installs
fstubner Oct 7, 2026
bae1291
Show why an install cannot update itself in the update notice
fstubner Oct 7, 2026
1a0780a
Make the crash screen readable and escapable, and refuse bundles that…
fstubner Oct 7, 2026
e70d326
Quote every value in the copied command so a bundle cannot plant a sh…
fstubner Oct 7, 2026
e607ba8
Cap how many packets opening a capture reads when the Packets field i…
fstubner Oct 7, 2026
38789bf
Match control characters with a Unicode property so the quoting helpe…
fstubner Oct 7, 2026
7a46fbf
Stop DNS Lookup ALL from asking for the rest of the record types afte…
fstubner Oct 7, 2026
34bb3d4
Apply rustfmt to open_result_bundle
fstubner Oct 7, 2026
dee4118
Read a damaged save-settings file as the defaults and write it atomic…
fstubner Oct 7, 2026
9c4ff2a
Start CSV files saved by the desktop app with a UTF-8 byte-order mark
fstubner Oct 7, 2026
73b7ddf
Fix two keyboard and screen reader gaps in the tab strip and workspac…
fstubner Oct 7, 2026
a861a20
Keep error toasts until dismissed and take the update dialog out of t…
fstubner Oct 7, 2026
79a75f3
Stop the tab spinner turning when the system asks for reduced motion
fstubner Oct 7, 2026
e4a2a24
Add tests for what the renderer may write and open
fstubner Oct 7, 2026
e5ede93
Point the capability description at the component that exists
fstubner Oct 7, 2026
a7e1df8
Tighten the content security policy: no base, form or object, and no …
fstubner Oct 7, 2026
8f413a5
Avoid Array.at in the announcer test so the build type-checks
fstubner Oct 7, 2026
9837dd2
Say plainly that no published desktop installer has packet capture
fstubner Oct 7, 2026
aa0eea0
Stop rebuilding result-sized data on every render
fstubner Oct 7, 2026
864b261
Style the crash screen's Reload button so it can be read
fstubner Oct 7, 2026
389dbb0
Add the desktop app fixes to the 0.3.5 notes
fstubner Oct 7, 2026
6ff9c35
Move the command quoting tests to their own file to stay under the si…
fstubner Oct 7, 2026
3a3d15f
Say only what was measured in the comments on the console window and …
fstubner Oct 7, 2026
91c8387
Refuse a trace target that the trace tool would read as an option
fstubner Oct 7, 2026
879ce54
Add the trace option refusal to the 0.3.5 notes and say what the old …
fstubner Oct 7, 2026
1efa7bb
Put the measured window freeze in the dialog entry of the 0.3.5 notes
fstubner Oct 7, 2026
a412063
Move the trace tests into their own file to stay under the size cap
fstubner Oct 7, 2026
893ad94
Merge remote-tracking branch 'origin/main' into fix/desktop-app
fstubner Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>` 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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/netscli-gui/src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
57 changes: 57 additions & 0 deletions apps/netscli-gui/src-tauri/src/commands/files/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,60 @@ fn is_in_allowed_save_root(path: &Path) -> Result<bool, String> {

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(" ", &registry).is_err());
assert!(validate_saved_artifact_path("definitely/not/here.csv", &registry).is_err());

let exe = temp_file("exe", "tool.exe");
let err = validate_saved_artifact_path(exe.to_str().unwrap(), &registry).unwrap_err();
assert!(err.contains("not supported"), "{err}");

let dir = exe.parent().unwrap();
let err = validate_saved_artifact_path(dir.to_str().unwrap(), &registry).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(), &registry).unwrap();
assert_eq!(accepted, std::fs::canonicalize(&file).unwrap());
std::fs::remove_dir_all(file.parent().unwrap()).unwrap();
}
}
76 changes: 76 additions & 0 deletions apps/netscli-gui/src-tauri/src/commands/files/dialog.rs
Original file line number Diff line number Diff line change
@@ -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<T: Send + 'static>(
show: impl FnOnce(Box<dyn FnOnce(Option<T>) + Send>),
) -> Option<T> {
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<String> = ask(drop).await;
assert_eq!(chosen, None);
}
}
Loading
Loading