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")}
-
- {t("troubleReport")}
- {t("soon")}
+
+ {saving ? t("busy") : t("troubleReport")}
-
+
{t("troubleLog")}
- {t("soon")}
+ {report && (
+
+ {t("reportSaved")}{" "}
+ {
+ revealItemInDir(report).catch(() => setTrouble(t("logRefused")));
+ }}
+ >
+ {t("reportShow")}
+
+
+ )}
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")));
+}