From 14cccb90ee3020209e5cb202c74f76dea44ec115 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sat, 10 Oct 2026 13:26:32 -0300 Subject: [PATCH 1/4] feat(about): save a trouble report with the user name and home folder blanked out, and open the log folder --- PRIVACY.md | 2 +- app/src-tauri/src/lib.rs | 3 + app/src-tauri/src/report.rs | 214 +++++++++++++++++++++++++++++++ app/src-tauri/src/report_test.rs | 83 ++++++++++++ app/src/index.css | 11 -- app/src/locales.ts | 14 +- app/src/ui/About.tsx | 42 +++++- 7 files changed, 347 insertions(+), 22 deletions(-) create mode 100644 app/src-tauri/src/report.rs create mode 100644 app/src-tauri/src/report_test.rs diff --git a/PRIVACY.md b/PRIVACY.md index 54fa2f86..a1afbae6 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -66,7 +66,7 @@ To be absolutely clear: - ❌ **Does not use advertising or ad networks** - ❌ **Does not send your data to any cloud AI** — The text in your pictures is read on your machine, by the operating system itself (Windows OCR on Windows, Apple Vision on macOS) - ❌ **Does not sync across devices** -- ❌ **Does not upload crash reports** — A crash is written to the local log beside the history, and sharing it is a file you attach yourself +- ❌ **Does not upload crash reports** — A crash is written to the local log beside the history, and sharing it is a file you attach yourself. **Save a report…** in About puts that log, the version and the system into one file, with your user name and home folder blanked out, wherever you choose - ❌ **Does not phone home** — No background network calls except the update checker described below (all platforms) --- diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b98f6b51..bf78823f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod links; mod looks; mod note; mod panel; +mod report; mod reviving; mod settings; mod tray; @@ -45,6 +46,8 @@ pub fn run() { backup::load_backup, backup::bring_former, backup::drop_former, + report::save_report, + report::open_log, update::update_ready, update::install_route, update::update_install, diff --git a/app/src-tauri/src/report.rs b/app/src-tauri/src/report.rs new file mode 100644 index 00000000..f5043ed2 --- /dev/null +++ b/app/src-tauri/src/report.rs @@ -0,0 +1,214 @@ +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; + +const TAIL: u64 = 2 * 1024 * 1024; +const LINES: usize = 2_000; +const LOGS: [&str; 4] = [ + "cp-gui.log.1", + "cp-gui.log", + "cp-panel.log.1", + "cp-panel.log", +]; + +fn logs() -> PathBuf { + let log = crate::note::where_to(); + log.parent() + .map_or_else(std::env::temp_dir, Path::to_path_buf) +} + +#[tauri::command] +pub fn open_log(app: tauri::AppHandle) -> Result<(), String> { + let dir = logs(); + std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; + app.opener() + .open_path(dir.to_string_lossy(), None::<&str>) + .map_err(|why| why.to_string()) +} + +#[tauri::command(async)] +pub fn save_report( + app: tauri::AppHandle, + window: tauri::WebviewWindow, +) -> Result, String> { + let Some(chosen) = app + .dialog() + .file() + .set_parent(&window) + .set_file_name(named(&cp_core::stamp::now())) + .add_filter("Text", &["txt"]) + .blocking_save_file() + else { + return Ok(None); + }; + let path = chosen.into_path().map_err(|why| why.to_string())?; + let version = app.package_info().version.to_string(); + std::fs::write(&path, report(&version)).map_err(|why| why.to_string())?; + crate::note::note("a report was saved"); + Ok(Some(path.to_string_lossy().into_owned())) +} + +pub fn named(stamp: &str) -> String { + let day = stamp.get(..10).unwrap_or("today"); + format!("copypaste-report-{day}.txt") +} + +fn report(version: &str) -> String { + let route = match crate::update::route() { + crate::update::Route::Store => "Microsoft Store", + crate::update::Route::Download => "download", + }; + let mut out = format!( + "CopyPaste {version}\n{}\n{} {}\ninstalled from: {route}\nwritten: {}\n", + system(), + std::env::consts::OS, + std::env::consts::ARCH, + cp_core::stamp::now() + ); + let dir = logs(); + for name in LOGS { + out.push_str(&format!("\n===== {name} =====\n")); + match tail_of(&dir.join(name), TAIL) { + Some(text) => out.push_str(&fold(&text, LINES)), + None => out.push_str("(not there)\n"), + } + } + let home = std::env::home_dir().map(|at| at.to_string_lossy().into_owned()); + let user = std::env::var("USERNAME") + .or_else(|_| std::env::var("USER")) + .ok(); + redact(&out, home.as_deref(), user.as_deref()) +} + +pub fn tail_of(path: &Path, up_to: u64) -> Option { + let mut file = std::fs::File::open(path).ok()?; + let size = file.metadata().ok()?.len(); + let from = size.saturating_sub(up_to); + file.seek(SeekFrom::Start(from)).ok()?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).ok()?; + let text = String::from_utf8_lossy(&bytes); + let whole = if from > 0 { + text.split_once('\n').map_or("", |(_, rest)| rest) + } else { + &text + }; + Some(whole.to_owned()) +} + +pub fn fold(text: &str, keep: usize) -> String { + let mut lines: Vec = Vec::new(); + let mut said: Option<&str> = None; + let mut again = 0; + for line in text.lines() { + let what = line.splitn(4, ' ').nth(3).unwrap_or(line); + if said == Some(what) { + again += 1; + continue; + } + if again > 0 { + lines.push(repeated(again)); + } + again = 0; + said = Some(what); + lines.push(line.to_owned()); + } + if again > 0 { + lines.push(repeated(again)); + } + let from = lines.len().saturating_sub(keep); + let mut out = lines[from..].join("\n"); + out.push('\n'); + out +} + +fn repeated(again: usize) -> String { + if again == 1 { + return " (the line above, once more)".into(); + } + format!(" (the line above, {again} more times)") +} + +pub fn redact(text: &str, home: Option<&str>, user: Option<&str>) -> String { + let mut out = text.to_owned(); + if let Some(home) = home.filter(|it| it.len() > 3) { + out = swap(&out, home, "~", false); + out = swap(&out, &home.replace('\\', "/"), "~", false); + } + if let Some(user) = user.filter(|it| it.len() > 1) { + out = swap(&out, user, "", true); + } + out +} + +fn swap(text: &str, needle: &str, with: &str, whole_word: bool) -> String { + let lower = text.to_ascii_lowercase(); + let wanted = needle.to_ascii_lowercase(); + let mut out = String::with_capacity(text.len()); + let mut kept = 0; + let mut from = 0; + while let Some(found) = lower[from..].find(&wanted) { + let start = from + found; + let end = start + wanted.len(); + from = end; + if whole_word + && !(bounded(text[..start].chars().next_back()) && bounded(text[end..].chars().next())) + { + continue; + } + out.push_str(&text[kept..start]); + out.push_str(with); + kept = end; + } + out.push_str(&text[kept..]); + out +} + +fn bounded(next: Option) -> bool { + next.is_none_or(|it| !it.is_alphanumeric()) +} + +#[cfg(windows)] +fn system() -> String { + use winreg::enums::HKEY_LOCAL_MACHINE; + let Ok(key) = winreg::RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") + else { + return "Windows".into(); + }; + let read = |name: &str| key.get_value::(name).unwrap_or_default(); + let build = read("CurrentBuildNumber"); + let name = read("ProductName"); + let name = if build.parse::().is_ok_and(|it| it >= 22_000) { + name.replace("Windows 10", "Windows 11") + } else { + name + }; + let patch = key + .get_value::("UBR") + .map(|it| format!(".{it}")) + .unwrap_or_default(); + format!("{name} {} (build {build}{patch})", read("DisplayVersion")) +} + +#[cfg(target_os = "macos")] +fn system() -> String { + let said = std::process::Command::new("/usr/bin/sw_vers") + .arg("-productVersion") + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned()) + .unwrap_or_default(); + format!("macOS {said}") +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn system() -> String { + std::env::consts::OS.into() +} + +#[cfg(test)] +#[path = "report_test.rs"] +mod tests; diff --git a/app/src-tauri/src/report_test.rs b/app/src-tauri/src/report_test.rs new file mode 100644 index 00000000..bcfb0074 --- /dev/null +++ b/app/src-tauri/src/report_test.rs @@ -0,0 +1,83 @@ +use super::*; + +#[test] +fn the_home_folder_is_hidden_however_its_slashes_or_case_are_written() { + let said = redact( + "a copy written to C:\\Users\\Mario\\Desktop\\x.copypaste\nopened c:/users/mario/AppData", + Some("C:\\Users\\Mario"), + None, + ); + assert_eq!( + said, + "a copy written to ~\\Desktop\\x.copypaste\nopened ~/AppData" + ); +} + +#[test] +fn the_user_name_is_hidden_only_as_a_whole_word() { + let said = redact( + "D:\\mario\\backup and MARIO~1 but not marionette", + Some("C:\\Users\\Mario"), + Some("mario"), + ); + assert_eq!(said, "D:\\\\backup and ~1 but not marionette"); +} + +#[test] +fn a_name_too_short_to_hide_safely_is_left_alone() { + let said = redact("a b c", Some("/"), Some("a")); + assert_eq!(said, "a b c"); +} + +#[test] +fn the_tail_starts_on_a_whole_line_and_keeps_the_end() { + let dir = std::env::temp_dir().join("cp-report-tests"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("tail.log"); + std::fs::write(&path, "first line\nsecond line\nthird\n").expect("written"); + + assert_eq!( + tail_of(&path, 12).as_deref(), + Some("third\n"), + "a line cut in half would read as a different message" + ); + assert_eq!( + tail_of(&path, 1024).as_deref(), + Some("first line\nsecond line\nthird\n") + ); + assert_eq!(tail_of(&dir.join("missing.log"), 1024), None); +} + +#[test] +fn the_report_is_named_after_the_day_it_was_saved() { + assert_eq!( + named("2026-10-10 12:00:00Z"), + "copypaste-report-2026-10-10.txt" + ); +} + +#[test] +fn a_line_said_over_and_over_is_written_once_with_its_count() { + let log = [ + "2026-10-10 10:00:00Z 7 started", + "2026-10-10 10:00:01Z 7 the panel said: Slint chain", + "2026-10-10 10:00:02Z 7 the panel said: Slint chain", + "2026-10-10 10:00:03Z 7 the panel said: Slint chain", + "2026-10-10 10:00:04Z 7 done", + ] + .join("\n"); + let folded = [ + "2026-10-10 10:00:00Z 7 started", + "2026-10-10 10:00:01Z 7 the panel said: Slint chain", + " (the line above, 2 more times)", + "2026-10-10 10:00:04Z 7 done", + "", + ] + .join("\n"); + assert_eq!(fold(&log, 100), folded); +} + +#[test] +fn only_the_last_lines_are_kept() { + assert_eq!(fold("a 1 1 x\nb 1 1 y\nc 1 1 z\n", 2), "b 1 1 y\nc 1 1 z\n"); +} diff --git a/app/src/index.css b/app/src/index.css index 32bbe820..47939f33 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -607,17 +607,6 @@ button:disabled { background: var(--field); } -.soon { - display: inline-block; - margin-left: 8px; - border: 1px solid var(--hair); - border-radius: var(--r-inset); - padding: 2px 7px; - font-size: var(--fs-caption); - color: var(--muted); - vertical-align: middle; -} - .brow { display: flex; align-items: center; diff --git a/app/src/locales.ts b/app/src/locales.ts index 0cfbb4f9..4baa47c8 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -190,11 +190,15 @@ const ES = { toolLinkUnboundHere: "Instalado · tus enlaces salen por él", troubleTitle: "Si algo va mal", troubleWhat: - "El informe junta en un archivo el registro de CopyPaste, su versión y qué Windows o macOS usas.", + "El informe junta en un archivo el registro de CopyPaste, su versión y qué Windows o macOS usas, con tu nombre de usuario y tu carpeta personal tapados.", troubleNeverSent: "No se envía a ninguna parte", troubleYours: ". Se guarda donde tú elijas y solo lo adjuntas si quieres.", troubleReport: "Guardar informe…", troubleLog: "Abrir el registro", + reportSaved: "Informe guardado.", + reportShow: "Mostrar en la carpeta", + reportRefused: "No se pudo guardar el informe", + logRefused: "No se pudo abrir la carpeta del registro", aboutRepo: "Repositorio", aboutAlternative: "AlternativeTo", aboutNotices: "Avisos de terceros", @@ -202,7 +206,6 @@ const ES = { aboutLicences: "Textos de las licencias", licencesRefused: "No se pudieron leer los textos de las licencias", linkRefused: "No se pudo abrir {one}", - soon: "Todavía no", tryAgain: "Reintentar", updateNone: "Estás en la última versión", updateWhen: "Se mira una vez al día, y cuando tú lo pidas", @@ -525,11 +528,15 @@ const EN: Record = { toolLinkUnboundHere: "Installed · your links open through it", troubleTitle: "If something goes wrong", troubleWhat: - "The report puts CopyPaste's log, its version and which Windows or macOS you use into one file.", + "The report puts CopyPaste's log, its version and which Windows or macOS you use into one file, with your user name and home folder blanked out.", troubleNeverSent: "It is never sent anywhere", troubleYours: ". It is saved where you choose, and you attach it only if you want to.", troubleReport: "Save a report…", troubleLog: "Open the log", + reportSaved: "Report saved.", + reportShow: "Show in folder", + reportRefused: "The report could not be saved", + logRefused: "The log folder could not be opened", aboutRepo: "Repository", aboutAlternative: "AlternativeTo", aboutNotices: "Third-party notices", @@ -537,7 +544,6 @@ const EN: Record = { aboutLicences: "Licence texts", licencesRefused: "The licence texts could not be read", linkRefused: "{one} could not be opened", - soon: "Not yet", tryAgain: "Try again", updateNone: "You are on the latest version", updateWhen: "Looked up once a day, and whenever you ask", diff --git a/app/src/ui/About.tsx b/app/src/ui/About.tsx index 53998f17..996361ab 100644 --- a/app/src/ui/About.tsx +++ b/app/src/ui/About.tsx @@ -1,6 +1,6 @@ import { getVersion } from "@tauri-apps/api/app"; import { invoke } from "@tauri-apps/api/core"; -import { openUrl } from "@tauri-apps/plugin-opener"; +import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import { useEffect, useRef, useState } from "react"; import { licences, notices, type Route, useUpdate } from "../core"; import { fill, t } from "../locales"; @@ -107,6 +107,8 @@ export default function About() { const [fromStore, setFromStore] = useState(false); const [said, setSaid] = useState(null); const [texts, setTexts] = useState(null); + const [report, setReport] = useState(null); + const [saving, setSaving] = useState(false); useEffect(() => { getVersion() @@ -135,6 +137,22 @@ export default function About() { asked.catch(() => setTrouble(fill("linkRefused", where))); }; + const saveReport = () => { + setTrouble(null); + setSaving(true); + invoke("save_report") + .then((path) => { + if (path) setReport(path); + }) + .catch(() => setTrouble(t("reportRefused"))) + .finally(() => setSaving(false)); + }; + + const openLog = () => { + setTrouble(null); + invoke("open_log").catch(() => setTrouble(t("logRefused"))); + }; + return ( <> {trouble &&

{trouble}

} @@ -272,15 +290,27 @@ export default function About() { {t("troubleYours")}

- -
+ {report && ( +

+ {t("reportSaved")}{" "} + +

+ )}
From 71803ae61a04eb9811a81d7bb2222987b5c1a1c7 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sat, 10 Oct 2026 13:30:56 -0300 Subject: [PATCH 2/4] fix(about): open the log folder Store installs really write to, hide accented names in any case --- app/src-tauri/src/report.rs | 43 ++++++++++++++++++++++++----- app/src-tauri/src/report_test.rs | 10 +++++++ app/src/locales.ts | 2 ++ app/src/ui/About.tsx | 2 +- crates/cp-win-sys/src/paths.rs | 24 ++++++++++++---- crates/cp-win-sys/src/paths_test.rs | 13 +++++++++ 6 files changed, 80 insertions(+), 14 deletions(-) diff --git a/app/src-tauri/src/report.rs b/app/src-tauri/src/report.rs index f5043ed2..b6d6348d 100644 --- a/app/src-tauri/src/report.rs +++ b/app/src-tauri/src/report.rs @@ -23,11 +23,27 @@ fn logs() -> PathBuf { pub fn open_log(app: tauri::AppHandle) -> Result<(), String> { let dir = logs(); std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; + let dir = seen_outside(dir); app.opener() .open_path(dir.to_string_lossy(), None::<&str>) .map_err(|why| why.to_string()) } +#[cfg(windows)] +fn seen_outside(dir: PathBuf) -> PathBuf { + if crate::update::route() != crate::update::Route::Store { + return dir; + } + cp_win_sys::paths::in_package(&dir) + .filter(|it| it.exists()) + .unwrap_or(dir) +} + +#[cfg(not(windows))] +fn seen_outside(dir: PathBuf) -> PathBuf { + dir +} + #[tauri::command(async)] pub fn save_report( app: tauri::AppHandle, @@ -144,15 +160,15 @@ pub fn redact(text: &str, home: Option<&str>, user: Option<&str>) -> String { } fn swap(text: &str, needle: &str, with: &str, whole_word: bool) -> String { - let lower = text.to_ascii_lowercase(); - let wanted = needle.to_ascii_lowercase(); let mut out = String::with_capacity(text.len()); let mut kept = 0; - let mut from = 0; - while let Some(found) = lower[from..].find(&wanted) { - let start = from + found; - let end = start + wanted.len(); - from = end; + for (start, _) in text.char_indices() { + if start < kept { + continue; + } + let Some(end) = found_at(text, start, needle) else { + continue; + }; if whole_word && !(bounded(text[..start].chars().next_back()) && bounded(text[end..].chars().next())) { @@ -166,6 +182,19 @@ fn swap(text: &str, needle: &str, with: &str, whole_word: bool) -> String { out } +fn found_at(text: &str, start: usize, needle: &str) -> Option { + let mut there = text[start..].char_indices(); + let mut end = start; + for wanted in needle.chars() { + let (at, got) = there.next()?; + if got != wanted && !got.to_lowercase().eq(wanted.to_lowercase()) { + return None; + } + end = start + at + got.len_utf8(); + } + Some(end) +} + fn bounded(next: Option) -> bool { next.is_none_or(|it| !it.is_alphanumeric()) } diff --git a/app/src-tauri/src/report_test.rs b/app/src-tauri/src/report_test.rs index bcfb0074..12eb8906 100644 --- a/app/src-tauri/src/report_test.rs +++ b/app/src-tauri/src/report_test.rs @@ -81,3 +81,13 @@ fn a_line_said_over_and_over_is_written_once_with_its_count() { fn only_the_last_lines_are_kept() { assert_eq!(fold("a 1 1 x\nb 1 1 y\nc 1 1 z\n", 2), "b 1 1 y\nc 1 1 z\n"); } + +#[test] +fn a_name_with_accents_is_hidden_whatever_its_case() { + let said = redact( + r"C:\USERS\JOSÉ\x and müller and MÜLLER", + Some(r"C:\Users\José"), + Some("Müller"), + ); + assert_eq!(said, r"~\x and and "); +} diff --git a/app/src/locales.ts b/app/src/locales.ts index 4baa47c8..e4fc1194 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -198,6 +198,7 @@ const ES = { reportSaved: "Informe guardado.", reportShow: "Mostrar en la carpeta", reportRefused: "No se pudo guardar el informe", + reportShowRefused: "No se encontró el informe donde se guardó", logRefused: "No se pudo abrir la carpeta del registro", aboutRepo: "Repositorio", aboutAlternative: "AlternativeTo", @@ -536,6 +537,7 @@ const EN: Record = { reportSaved: "Report saved.", reportShow: "Show in folder", reportRefused: "The report could not be saved", + reportShowRefused: "The report is no longer where it was saved", logRefused: "The log folder could not be opened", aboutRepo: "Repository", aboutAlternative: "AlternativeTo", diff --git a/app/src/ui/About.tsx b/app/src/ui/About.tsx index 996361ab..17298146 100644 --- a/app/src/ui/About.tsx +++ b/app/src/ui/About.tsx @@ -304,7 +304,7 @@ export default function About() { type="button" className="welcome-link" onClick={() => { - revealItemInDir(report).catch(() => setTrouble(t("logRefused"))); + revealItemInDir(report).catch(() => setTrouble(t("reportShowRefused"))); }} > {t("reportShow")} diff --git a/crates/cp-win-sys/src/paths.rs b/crates/cp-win-sys/src/paths.rs index f7764c16..5df2469e 100644 --- a/crates/cp-win-sys/src/paths.rs +++ b/crates/cp-win-sys/src/paths.rs @@ -21,18 +21,30 @@ pub fn legacy_in(local: &std::path::Path) -> PathBuf { if plain.join("clipboard.db").exists() { return plain; } - let packaged = local - .join("Packages") - .join(STORE_FAMILY) - .join("LocalCache") - .join("Local") - .join("CopyPaste"); + let packaged = in_package_of(local, &plain).unwrap_or_else(|| plain.clone()); if packaged.join("clipboard.db").exists() { return packaged; } plain } +pub fn in_package(path: &std::path::Path) -> Option { + let local = std::env::var_os("LOCALAPPDATA")?; + in_package_of(std::path::Path::new(&local), path) +} + +pub fn in_package_of(local: &std::path::Path, path: &std::path::Path) -> Option { + let rest = path.strip_prefix(local).ok()?; + Some( + local + .join("Packages") + .join(STORE_FAMILY) + .join("LocalCache") + .join("Local") + .join(rest), + ) +} + pub fn legacy_database() -> Option { Some(legacy_dir()?.join("clipboard.db")) } diff --git a/crates/cp-win-sys/src/paths_test.rs b/crates/cp-win-sys/src/paths_test.rs index 4c86d338..06abef58 100644 --- a/crates/cp-win-sys/src/paths_test.rs +++ b/crates/cp-win-sys/src/paths_test.rs @@ -97,3 +97,16 @@ fn with_no_2x_anywhere_the_plain_folder_is_the_answer() { assert_eq!(legacy_in(&local), local.join("CopyPaste")); let _ = std::fs::remove_dir_all(&local); } + +#[test] +fn a_folder_under_local_app_data_has_its_twin_inside_the_package() { + let local = std::path::Path::new(r"C:\Users\u\AppData\Local"); + assert_eq!( + in_package_of(local, &local.join("CopyPaste").join("logs")), + Some(packaged_in(local).join("logs")) + ); + assert_eq!( + in_package_of(local, std::path::Path::new(r"D:\elsewhere")), + None + ); +} From 0943de2cde72651f494609e315dd806f7c656c74 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sat, 10 Oct 2026 13:37:50 -0300 Subject: [PATCH 3/4] build(deps): move katex, smol-toml, source-map-js and qs past their advisories --- app/package-lock.json | 23 ++++++++++++----------- app/package.json | 2 +- package-lock.json | 22 +++++++++++----------- package.json | 4 ++++ 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 9b28cea2..99f9163c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -4690,13 +4690,14 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -4971,9 +4972,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5161,15 +5162,15 @@ } }, "node_modules/typed-rest-client": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.3.1.tgz", - "integrity": "sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.3.0.tgz", + "integrity": "sha512-FfBj5tjviexjIus3La4n4s9i+f81Zj7HU+lUlQWK219HMRfmzLsbIf4PZF2+X6EouJKyuANpvvef5VrUWM4AFw==", "dev": true, "license": "MIT", "dependencies": { "des.js": "^1.1.0", "js-md4": "^0.3.2", - "qs": "6.15.1", + "qs": "^6.14.1", "tunnel": "0.0.6", "underscore": "^1.13.8" }, diff --git a/app/package.json b/app/package.json index 4f398ad6..4827820a 100644 --- a/app/package.json +++ b/app/package.json @@ -44,6 +44,6 @@ "vitest": "4.1.11" }, "overrides": { - "qs": "6.15.2" + "qs": "6.16.0" } } diff --git a/package-lock.json b/package-lock.json index 2f31fa15..50b652c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -158,13 +158,13 @@ } }, "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=22.12.0" } }, "node_modules/debug": { @@ -469,9 +469,9 @@ } }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.18.10", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.10.tgz", + "integrity": "sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==", "dev": true, "funding": [ "https://opencollective.com/katex", @@ -479,7 +479,7 @@ ], "license": "MIT", "dependencies": { - "commander": "^8.3.0" + "commander": "^15.0.0" }, "bin": { "katex": "cli.js" @@ -1301,9 +1301,9 @@ } }, "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index 4d01dd50..2657e44a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,10 @@ "devDependencies": { "markdownlint-cli2": "^0.23.2" }, + "overrides": { + "katex": "^0.18.2", + "smol-toml": "^1.9.0" + }, "scripts": { "prepare": "git config core.hooksPath hooks || echo the hooks are not set: this is not a git clone", "lint:md": "markdownlint-cli2", From f11a4e2b221b68dd6f3528588656fe5dd0cf8eda Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sat, 10 Oct 2026 13:40:54 -0300 Subject: [PATCH 4/4] test(about): cover the report and log buttons and the package twin of a folder --- app/src/tests/trouble.test.tsx | 94 +++++++++++++++++++++++++++++ crates/cp-win-sys/src/paths_test.rs | 10 +++ 2 files changed, 104 insertions(+) create mode 100644 app/src/tests/trouble.test.tsx diff --git a/app/src/tests/trouble.test.tsx b/app/src/tests/trouble.test.tsx new file mode 100644 index 00000000..60958782 --- /dev/null +++ b/app/src/tests/trouble.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import About from "../ui/About"; + +type Invoke = (what: string, args?: never) => Promise; + +async function answering(answers: Record Promise>) { + const { invoke } = await import("@tauri-apps/api/core"); + const real = vi.mocked(invoke).getMockImplementation() as Invoke; + const asked: string[] = []; + vi.mocked(invoke).mockImplementation(((what: string, args?: never) => { + const answer = answers[what]; + if (!answer) return real(what, args); + asked.push(what); + return answer(); + }) as never); + return { + asked, + undo: () => vi.mocked(invoke).mockImplementation(real as never), + }; +} + +describe("the buttons for when something goes wrong", () => { + const undo: (() => void)[] = []; + + afterEach(() => { + while (undo.length > 0) { + undo.pop()?.(); + } + }); + + it("save a report and offer to show it where it was saved", async () => { + const who = userEvent.setup(); + const said = await answering({ save_report: () => Promise.resolve("/donde/informe.txt") }); + undo.push(said.undo); + const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + render(); + + await who.click(await screen.findByRole("button", { name: "Guardar informe…" })); + expect(await screen.findByText("Informe guardado.")).toBeDefined(); + expect(said.asked).toEqual(["save_report"]); + + await who.click(screen.getByRole("button", { name: "Mostrar en la carpeta" })); + expect(vi.mocked(revealItemInDir)).toHaveBeenCalledWith("/donde/informe.txt"); + }); + + it("say nothing when the save dialog is closed without choosing", async () => { + const who = userEvent.setup(); + const said = await answering({ save_report: () => Promise.resolve(null) }); + undo.push(said.undo); + render(); + + const button = await screen.findByRole("button", { name: "Guardar informe…" }); + await who.click(button); + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)); + expect(screen.queryByText("Informe guardado.")).toBeNull(); + expect(screen.queryByRole("button", { name: "Mostrar en la carpeta" })).toBeNull(); + }); + + it("tell when the report could not be saved", async () => { + const who = userEvent.setup(); + const said = await answering({ save_report: () => Promise.reject("denied") }); + undo.push(said.undo); + render(); + + await who.click(await screen.findByRole("button", { name: "Guardar informe…" })); + expect(await screen.findByText("No se pudo guardar el informe")).toBeDefined(); + }); + + it("tell when the saved report is no longer there to be shown", async () => { + const who = userEvent.setup(); + const said = await answering({ save_report: () => Promise.resolve("/donde/informe.txt") }); + undo.push(said.undo); + const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + vi.mocked(revealItemInDir).mockRejectedValueOnce("gone"); + render(); + + await who.click(await screen.findByRole("button", { name: "Guardar informe…" })); + await who.click(await screen.findByRole("button", { name: "Mostrar en la carpeta" })); + expect(await screen.findByText("No se encontró el informe donde se guardó")).toBeDefined(); + }); + + it("open the log folder, and tell when it would not open", async () => { + const who = userEvent.setup(); + const said = await answering({ open_log: () => Promise.reject("denied") }); + undo.push(said.undo); + render(); + + await who.click(await screen.findByRole("button", { name: "Abrir el registro" })); + expect(said.asked).toEqual(["open_log"]); + expect(await screen.findByText("No se pudo abrir la carpeta del registro")).toBeDefined(); + }); +}); diff --git a/crates/cp-win-sys/src/paths_test.rs b/crates/cp-win-sys/src/paths_test.rs index 06abef58..0196d1b3 100644 --- a/crates/cp-win-sys/src/paths_test.rs +++ b/crates/cp-win-sys/src/paths_test.rs @@ -110,3 +110,13 @@ fn a_folder_under_local_app_data_has_its_twin_inside_the_package() { None ); } + +#[test] +fn the_package_twin_is_looked_for_under_this_machine_s_local_app_data() { + let Some(dir) = data_dir() else { + return; + }; + let local = std::path::PathBuf::from(std::env::var_os("LOCALAPPDATA").expect("local")); + assert_eq!(in_package(&dir), in_package_of(&local, &dir)); + assert!(in_package(&dir).is_some_and(|it| it.ends_with("CopyPaste"))); +}