From ddd257fb6baa330865f9eb48ffdd221181ed9cf0 Mon Sep 17 00:00:00 2001 From: tw4 <88425310+tw4@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:40:44 +0300 Subject: [PATCH 1/3] docs: lead with auto-blur, mark winget as pending review --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dca41de..2f97bfd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@

Beautiful screenshots in seconds.
- Capture, auto-beautify, annotate, redact and share. Free and open source for Windows, macOS and Linux. + Capture, auto-beautify, annotate and share. Emails, card numbers, IPs and API keys are blurred automatically.
+ Free and open source for Windows, macOS and Linux.

@@ -38,6 +39,8 @@ A raw screenshot rarely looks good enough to share. Lumela turns it into a polis - **Private by design.** Capture, text recognition and redaction run on your device. Nothing is uploaded. - **Fast.** Native capture and a small installer (~10 MB). +If Lumela saves you some manual blurring, a ⭐ on GitHub helps other people find it. + ## Download Get the installer for your system from the [latest release](https://github.com/tw4/lumela/releases/latest): @@ -53,9 +56,10 @@ Or install it with a package manager: ```bash brew trust tw4/tap && brew install --cask tw4/tap/lumela # macOS (Homebrew) scoop install https://raw.githubusercontent.com/tw4/lumela/main/packaging/scoop/lumela.json # Windows (Scoop) -winget install tw4.Lumela # Windows (winget) ``` +winget (`tw4.Lumela`) is under review in [microsoft/winget-pkgs#476618](https://github.com/microsoft/winget-pkgs/pull/476618). + Lumela updates itself, and every update is signature-checked before it is installed. > [!NOTE] From bfd6975ce45e3a3eb73e6550a3c67a3fdd9d5789 Mon Sep 17 00:00:00 2001 From: tw4 <88425310+tw4@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:16:03 +0300 Subject: [PATCH 2/3] Redact API keys that wrap onto the next line Detection ran per OCR line, so a key that wrapped left its tail visible on the next line, and a key split into two short halves was not found at all. Follow a key across the line break when the next line starts below and to the left with a random-looking piece, or when both halves together look like a key. Adds unit tests and a wrapped-key screenshot fixture that runs on the platform OCR engine. --- src-tauri/src/redact.rs | 150 ++++++++++++++++++++--- src-tauri/tests/fixtures/wrapped-key.png | Bin 0 -> 20907 bytes 2 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 src-tauri/tests/fixtures/wrapped-key.png diff --git a/src-tauri/src/redact.rs b/src-tauri/src/redact.rs index 835c83c..ff1b497 100644 --- a/src-tauri/src/redact.rs +++ b/src-tauri/src/redact.rs @@ -163,9 +163,45 @@ fn card_hits(words: &[Word]) -> Vec { out } +fn tokenish(text: &str) -> bool { + !text.is_empty() && text.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '+' | '/' | '=')) +} + +/// A piece of a key rather than a word: it has a digit, or capitals after the first letter. +fn looks_random(text: &str) -> bool { + text.chars().any(|c| c.is_ascii_digit()) + || (text.chars().skip(1).any(|c| c.is_ascii_uppercase()) && text.chars().any(|c| c.is_ascii_lowercase())) +} + +/// Indices of `words[k]` and the token-like pieces right after it on the same line. +/// OCR often splits a long key into pieces ("…vXz1 aBc"). +fn with_adjacent_pieces(words: &[Word], k: usize) -> Vec { + let mut out = vec![k]; + let mut last = &words[k]; + for (j, next) in words.iter().enumerate().skip(k + 1) { + let gap = next.x - (last.x + last.w); + if gap > last.h * 0.8 || !tokenish(&next.text) { + break; + } + out.push(j); + last = next; + } + out +} + +/// True when `b`, the first word of a line, sits right under the line that ends with `a` +/// and starts further left: what a key looks like when it wraps. +fn wraps_onto(a: &Word, b: &Word) -> bool { + let gap = b.y - (a.y + a.h); + b.y > a.y && gap >= -a.h * 0.2 && gap <= a.h && b.x < a.x +} + pub fn detect(ocr: &OcrResult, cats: Categories) -> Vec { let mut hits = Vec::new(); + // Per line, which words are covered by an API key hit (used to follow wrapped keys). + let mut key_words: Vec> = Vec::with_capacity(ocr.lines.len()); for line in &ocr.lines { + let mut keys = vec![false; line.words.len()]; let mut used = vec![false; line.words.len()]; if cats.credit_cards { for h in card_hits(&line.words) { @@ -192,25 +228,41 @@ pub fn detect(ocr: &OcrResult, cats: Categories) -> Vec { None }; if let Some(c) = category { - let mut covered = vec![w]; - // OCR often splits a long key into pieces ("…vXz1 aBc"); take adjacent - // token-like pieces with it so nothing is left uncovered. - if c == Category::ApiKey { - let mut last = w; - for (j, next) in line.words.iter().enumerate().skip(k + 1) { - let gap = next.x - (last.x + last.w); - let tokenish = next.text.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '+' | '/' | '=')); - if gap > last.h * 0.8 || !tokenish { - break; - } - used[j] = true; - covered.push(next); - last = next; - } + let idx = if c == Category::ApiKey { with_adjacent_pieces(&line.words, k) } else { vec![k] }; + for &j in &idx { + used[j] = true; + keys[j] = c == Category::ApiKey; } - hits.push(hit(c, &covered)); + hits.push(hit(c, &idx.iter().map(|&j| &line.words[j]).collect::>())); } } + key_words.push(keys); + } + + // A key that does not fit on one line continues at the start of the next one. + if cats.api_keys { + for i in 0..ocr.lines.len().saturating_sub(1) { + let (la, lb) = (&ocr.lines[i].words, &ocr.lines[i + 1].words); + let (Some(a), Some(b)) = (la.last(), lb.first()) else { continue }; + let ai = la.len() - 1; + let tail = token(&a.text); + if key_words[i + 1][0] || !wraps_onto(a, b) || !tokenish(tail) || !tokenish(&b.text) || !looks_random(&b.text) { + continue; + } + let a_is_key = key_words[i][ai]; + if !a_is_key && !is_api_key(&format!("{tail}{}", b.text)) { + continue; + } + if !a_is_key { + key_words[i][ai] = true; + hits.push(hit(Category::ApiKey, &[a])); + } + let idx = with_adjacent_pieces(lb, 0); + for &j in &idx { + key_words[i + 1][j] = true; + } + hits.push(hit(Category::ApiKey, &idx.iter().map(|&j| &lb[j]).collect::>())); + } } hits } @@ -274,6 +326,48 @@ mod tests { assert_eq!(hits[0].x + hits[0].w, l.words[2].x + l.words[2].w); } + /// A second line starting at the left margin, just below `first`. + fn next_line(first: &Line, words: &[&str]) -> Line { + let mut l = line(words); + for w in &mut l.words { + w.y = first.words[0].y + first.words[0].h + 6.0; + } + l + } + + fn covers(h: &Hit, w: &Word) -> bool { + w.x >= h.x - 0.5 && w.x + w.w <= h.x + h.w + 0.5 && w.y >= h.y - 0.5 && w.y + w.h <= h.y + h.h + 0.5 + } + + #[test] + fn wrapped_key_tail_on_next_line_is_covered() { + // The first part is a key on its own; the short tail wraps to the next line. + let a = line(&["API", "key:", "sk-live-9fJ2kLm8QpRt"]); + let b = next_line(&a, &["5vXz1aBcQ7", "is", "active"]); + let hits = detect(&OcrResult { lines: vec![a.clone(), b.clone()] }, Categories::default()); + assert!(hits.iter().any(|h| h.category == Category::ApiKey && covers(h, &a.words[2])), "{hits:?}"); + assert!(hits.iter().any(|h| h.category == Category::ApiKey && covers(h, &b.words[0])), "{hits:?}"); + } + + #[test] + fn key_split_into_two_short_halves_across_lines_is_covered() { + // Neither half is long enough to look like a key alone; together they are. + let a = line(&["token:", "a8F3kQ9zLm2Xv7Tq"]); + let b = next_line(&a, &["B7nP0wRt5YcE4uHs"]); + let hits = detect(&OcrResult { lines: vec![a.clone(), b.clone()] }, Categories::default()); + assert!(hits.iter().any(|h| covers(h, &a.words[1])), "{hits:?}"); + assert!(hits.iter().any(|h| covers(h, &b.words[0])), "{hits:?}"); + } + + #[test] + fn ordinary_next_line_is_not_swallowed() { + let a = line(&["API", "key:", "sk-live-9fJ2kLm8QpRt5vXz1aBc"]); + let b = next_line(&a, &["Created", "yesterday"]); + let hits = detect(&OcrResult { lines: vec![a.clone(), b.clone()] }, Categories::default()); + assert_eq!(hits.len(), 1, "{hits:?}"); + assert!(!hits.iter().any(|h| covers(h, &b.words[0]))); + } + #[test] fn ignores_ordinary_text() { assert!(cats_found(&["Version", "1.2.3", "released", "on", "2026-11-10", "build", "12345"]).is_empty()); @@ -301,6 +395,30 @@ mod tests { ); } + /// Keys that wrap onto the next line, read by the real platform OCR engine. + #[cfg(any(windows, target_os = "macos", target_os = "linux"))] + #[test] + fn platform_ocr_covers_wrapped_keys_in_fixture() { + use crate::ocr::engine; + let lang = if cfg!(target_os = "linux") { "eng" } else { "en-US" }; + let img = image::open(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/wrapped-key.png")).unwrap().to_rgba8(); + let ocr = engine().recognize(&img, Some(lang)).or_else(|_| engine().recognize(&img, None)).unwrap(); + let hits = detect(&ocr, Categories::default()); + let lines: Vec<&String> = ocr.lines.iter().map(|l| &l.text).collect(); + // Each piece of both keys, on both lines, must be under an API key box. + for piece in ["9fJ2", "5vXz", "a8F3", "B7nP"] { + let words: Vec<&Word> = ocr.lines.iter().flat_map(|l| &l.words).filter(|w| w.text.contains(piece)).collect(); + assert!(!words.is_empty(), "OCR did not read {piece}: {lines:?}"); + for w in words { + assert!( + hits.iter().any(|h| h.category == Category::ApiKey && covers(h, w)), + "{piece} ({:?}) is not covered. OCR lines: {lines:?}, hits: {hits:?}", + w.text + ); + } + } + } + #[test] fn respects_disabled_categories() { let cats = Categories { emails: false, ..Default::default() }; diff --git a/src-tauri/tests/fixtures/wrapped-key.png b/src-tauri/tests/fixtures/wrapped-key.png new file mode 100644 index 0000000000000000000000000000000000000000..921f28e9de105789f007d5c605bd245d11fae08c GIT binary patch literal 20907 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 literal 0 HcmV?d00001 From c95a4457b1ced4ef47709890e0cef273134461b0 Mon Sep 17 00:00:00 2001 From: tw4 <88425310+tw4@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:20:06 +0300 Subject: [PATCH 3/3] Use a unique temp file per tesseract call The temp image was named after the process id only, so two OCR runs in the same process overwrote each other's image and one got the other's text. Two platform OCR tests running in parallel hit this on Linux. --- src-tauri/src/ocr/linux.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/ocr/linux.rs b/src-tauri/src/ocr/linux.rs index f0dfb48..81b5f3e 100644 --- a/src-tauri/src/ocr/linux.rs +++ b/src-tauri/src/ocr/linux.rs @@ -2,6 +2,7 @@ //! (e.g. `apt install tesseract-ocr tesseract-ocr-eng`). use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; use image::RgbaImage; @@ -9,13 +10,18 @@ use super::{tsv, OcrEngine, OcrLanguage, OcrResult}; pub struct Tesseract; +/// Each call gets its own temp file, so OCR runs that overlap in one process +/// (e.g. auto redact and text selection) never read each other's image. +static NEXT_FILE: AtomicU64 = AtomicU64::new(0); + fn not_found(e: std::io::Error) -> String { format!("Could not run tesseract ({e}). Install it with: sudo apt install tesseract-ocr") } impl OcrEngine for Tesseract { fn recognize(&self, image: &RgbaImage, language: Option<&str>) -> Result { - let path = std::env::temp_dir().join(format!("scs-ocr-{}.png", std::process::id())); + let n = NEXT_FILE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("scs-ocr-{}-{n}.png", std::process::id())); image.save(&path).map_err(|e| e.to_string())?; let mut cmd = Command::new("tesseract"); cmd.arg(&path).arg("stdout");