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");