From 1aac582e90f2c8f1d5dfcb4a22978918a6e2f4a8 Mon Sep 17 00:00:00 2001 From: Alan Silva Date: Sat, 5 Sep 2026 16:22:10 +0100 Subject: [PATCH] QR decode support (zbarimg) with searchable payload + preview panel; restructure repo root as installable omarchy plugin; plain-text safety for QR payloads --- .gitignore | 1 + plugin/Classify.js => Classify.js | 0 plugin/Clipboard.qml => Clipboard.qml | 13 +++- plugin/Fuzzy.js => Fuzzy.js | 0 plugin/PreviewPane.qml => PreviewPane.qml | 66 ++++++++++++++++++++- README.md | 29 ++++++--- plugin/Store.js => Store.js | 4 ++ plugin/capture.py => capture.py | 21 +++++++ install.sh | 8 ++- plugin/manifest.json => manifest.json | 0 plugin/open-entry.sh => open-entry.sh | 0 plugin/paste-entry.sh => paste-entry.sh | 0 plugin/__pycache__/capture.cpython-314.pyc | Bin 9426 -> 0 bytes tests/harness.mjs | 2 +- tests/test-store.mjs | 16 +++++ 15 files changed, 144 insertions(+), 16 deletions(-) rename plugin/Classify.js => Classify.js (100%) rename plugin/Clipboard.qml => Clipboard.qml (98%) rename plugin/Fuzzy.js => Fuzzy.js (100%) rename plugin/PreviewPane.qml => PreviewPane.qml (86%) rename plugin/Store.js => Store.js (96%) rename plugin/capture.py => capture.py (91%) rename plugin/manifest.json => manifest.json (100%) rename plugin/open-entry.sh => open-entry.sh (100%) rename plugin/paste-entry.sh => paste-entry.sh (100%) delete mode 100644 plugin/__pycache__/capture.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 751553b..99d3ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.bak +__pycache__/ diff --git a/plugin/Classify.js b/Classify.js similarity index 100% rename from plugin/Classify.js rename to Classify.js diff --git a/plugin/Clipboard.qml b/Clipboard.qml similarity index 98% rename from plugin/Clipboard.qml rename to Clipboard.qml index 84a7ffd..da55ee3 100644 --- a/plugin/Clipboard.qml +++ b/Clipboard.qml @@ -794,9 +794,15 @@ Item { var subtitleParts = [] if (e.type === "image") { - titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "") - if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h - subtitleParts.push(Classify.formatBytes(r.row.bytes)) + if (e.qr) { + titleHtml = Fuzzy.escapeHtml(Classify.firstLine(e.qr, 60)) + subtitleParts.push("QR") + if (e.mime) subtitleParts.push(e.mime.replace("image/", "").toUpperCase()) + } else { + titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "") + if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h + subtitleParts.push(Classify.formatBytes(r.row.bytes)) + } } else if (e.type === "files") { var base = Classify.fileBase(e.paths[0]) titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base) @@ -808,6 +814,7 @@ Item { } subtitleParts.push(Classify.typeLabel(derived)) + if (e.type === "image" && e.qr) subtitleParts.pop() // "QR" badge already says it if (r.row.app) subtitleParts.push(Classify.prettyApp(r.row.app)) if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000))) diff --git a/plugin/Fuzzy.js b/Fuzzy.js similarity index 100% rename from plugin/Fuzzy.js rename to Fuzzy.js diff --git a/plugin/PreviewPane.qml b/PreviewPane.qml similarity index 86% rename from plugin/PreviewPane.qml rename to PreviewPane.qml index 9322beb..773db04 100644 --- a/plugin/PreviewPane.qml +++ b/PreviewPane.qml @@ -57,6 +57,7 @@ Item { var e = r.entry var chips = [] if (r.app) chips.push(Classify.prettyApp(r.app)) + if (e.qr) chips.push("QR: " + Classify.firstLine(e.qr, 40)) if (e.type === "text" && e.text) { var st = Classify.textStats(e.text) chips.push(Classify.plural(st.words, "word")) @@ -105,7 +106,7 @@ Item { text: { if (!root.entry) return "" var e = root.entry - if (e.type === "image") return Classify.fileBase(e.path) + if (e.type === "image") return e.qr ? Classify.firstLine(e.qr, 120) : Classify.fileBase(e.path) if (e.type === "files") { var base = Classify.fileBase(e.paths[0]) return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base @@ -116,6 +117,7 @@ Item { font.family: root.font_ font.pixelSize: Style.font.title font.bold: true + textFormat: Text.PlainText elide: Text.ElideRight maximumLineCount: 1 } @@ -303,9 +305,68 @@ Item { visible: root.derived === "image" spacing: Style.space(8) + // Decoded QR payload sits above the image so it is immediately readable. + Rectangle { + visible: root.entry && root.entry.qr + width: parent.width + height: qrContent.height + Style.space(12) + radius: Style.cornerRadius + color: root.chipBg + + Column { + id: qrContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(6) + anchors.topMargin: Style.space(6) + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(4) + + Row { + spacing: Style.space(6) + + Text { + text: "󰐲" + color: Color.accent + font.family: root.font_ + font.pixelSize: Style.font.body + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "QR code content" + color: Color.accent + font.family: root.font_ + font.pixelSize: Style.font.caption + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + } + + TextEdit { + id: qrValue + width: parent.width + readOnly: true + activeFocusOnPress: false + text: root.entry && root.entry.qr ? root.entry.qr : "" + textFormat: TextEdit.PlainText + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.body + font.bold: true + wrapMode: TextEdit.WrapAnywhere + selectionColor: Util.alpha(Color.accent, 0.4) + } + } + } + Item { width: parent.width - height: parent.height - infoLabel.height - Style.space(12) + height: Math.max(0, parent.height + - (root.entry && root.entry.qr ? qrContent.height + Style.space(12) + Style.space(8) : 0) + - infoLabel.height - Style.space(12)) Image { id: img @@ -474,6 +535,7 @@ Item { id: chipLabel anchors.centerIn: parent text: parent.chipText + textFormat: Text.PlainText color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption diff --git a/README.md b/README.md index 6e2a14d..fb56571 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ rides on the system theme, fonts, and layer-shell infrastructure. type, byte size, source app (via `hyprctl`), timestamp, and image dimensions. Text, images, and `file://` URI lists (file-manager copies) are supported; password-manager clips and binary payloads are skipped. +- **QR codes** — copied QR images are decoded with `zbarimg`; the payload shows + in the list title, the preview pane (copy-selectable), and a metadata chip, + and is fuzzy-searchable like any text clip. - **Fuzzy search over everything** — fzf-style scoring over content, source app, and type, plus recency, pin, and usage boosts. Query tokens: - `type:image|link|text|files|code|json|color|email|html|number` (prefix match) @@ -46,22 +49,34 @@ rides on the system theme, fonts, and layer-shell infrastructure. ./install.sh ``` -This symlinks `plugin/` into `~/.config/omarchy/plugins/tank.clipboard`, -rescans + enables the plugin (the built-in `omarchy.clipboard` is replaced — -revert with `omarchy plugin disable tank.clipboard`), and rebinds -`ALT+SHIFT+V` from vicinae to this picker (backing up `bindings.conf`). +This symlinks the repo (the plugin root) into +`~/.config/omarchy/plugins/tank.clipboard`, rescans + enables the plugin (the +built-in `omarchy.clipboard` is replaced — revert with +`omarchy plugin disable tank.clipboard`), and rebinds `ALT+SHIFT+V` from +vicinae to this picker (backing up `bindings.conf`). + +On other machines, install straight from git — no clone/step needed: + +```bash +omarchy plugin add --enable +``` + +Note: the local dev symlink trips `omarchy plugin validate` (it refuses +symlinked plugin folders); a git-cloned copy validates clean. ## Layout +The repo root *is* the plugin folder (so `omarchy plugin add ` works +directly — it validates and clones a repo whose root holds `manifest.json`). + ``` -plugin/ ├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard) ├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers ├── PreviewPane.qml # per-type preview + metadata chips ├── Store.js # history model: dedup, pins, pruning, ids ├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting ├── Classify.js # type detection, app names, formatting, color math -├── capture.py # clipboard watcher → one JSON line per clip +├── capture.py # clipboard watcher → one JSON line per clip (incl. QR decode) ├── paste-entry.sh # copy + shift-insert paste into the focused window └── open-entry.sh # open with the right app @@ -73,7 +88,7 @@ History is stored in `~/.local/state/omarchy/clipboard-history-rich.json` ## Development -- Run logic tests: `tests/run.sh` (45 tests). +- Run logic tests: `tests/run.sh` (49 tests). - After editing files in the repo, reload with `omarchy-shell shell rescanPlugins` (the plugin dir is a symlink, so the shell's inotify does not watch repo edits). - Data lives in `~/.local/state/omarchy/`; the picker state is independent of diff --git a/plugin/Store.js b/Store.js similarity index 96% rename from plugin/Store.js rename to Store.js index 8fd553e..1ff9ac7 100644 --- a/plugin/Store.js +++ b/Store.js @@ -50,6 +50,7 @@ function normalize(value, now) { } if (value.w) out.w = Number(value.w) if (value.h) out.h = Number(value.h) + if (value.qr) out.qr = String(value.qr) } else if (type === "files") { var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : [] if (paths.length === 0) return null @@ -103,6 +104,8 @@ function addEntry(history, entry, now) { // Re-copy of existing content: keep its pin state and usage count. if (existing.pinned) normalized.pinned = true if (existing.uses > 0) normalized.uses = existing.uses + // Keep expensive derived data (e.g. decoded QR) across re-captures. + if (existing.qr && !normalized.qr) normalized.qr = existing.qr continue } next.push(existing) @@ -176,6 +179,7 @@ function buildRow(entry, derivedType, now) { var content = "" if (entry.type === "image") { content = fileLabel(entry.path) + " " + String(entry.mime || "") + if (entry.qr) content += " " + String(entry.qr).slice(0, 500) } else if (entry.type === "files") { content = (entry.paths || []).join(" ") } else { diff --git a/plugin/capture.py b/capture.py similarity index 91% rename from plugin/capture.py rename to capture.py index f9a7d78..262307a 100755 --- a/plugin/capture.py +++ b/capture.py @@ -65,6 +65,22 @@ def emit(entry): sys.stdout.flush() +def decode_qr(path): + """Decode a QR code with zbarimg; returns the payload or None.""" + try: + r = subprocess.run( + ["zbarimg", "-q", "--raw", "--", path], + capture_output=True, timeout=10 + ) + # zbarimg exits 4 when no barcode is found. + if r.returncode != 0: + return None + text = r.stdout.decode("utf-8", "replace").rstrip("\n") + return text or None + except Exception: + return None + + def capture_image(types, app): mime = next((m for m in IMAGE_MIMES if m in types), None) if not mime: @@ -98,6 +114,11 @@ def capture_image(types, app): except OSError: pass return + + qr = decode_qr(path) + if qr: + entry["qr"] = qr + emit(entry) return True diff --git a/install.sh b/install.sh index cb4bc00..18af3d1 100755 --- a/install.sh +++ b/install.sh @@ -1,21 +1,23 @@ #!/bin/bash # Install the tank.clipboard Omarchy shell plugin from this repo. # -# - symlinks plugin/ into ~/.config/omarchy/plugins/tank.clipboard +# - symlinks the repo root (the plugin folder) into ~/.config/omarchy/plugins/tank.clipboard # - rescans + enables it (shell.json gets plugins[] entry; the built-in # omarchy.clipboard is recorded in disabledPlugins[] and routed here) # - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf) set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_DIR="$REPO_DIR/plugin" +PLUGIN_DIR="$REPO_DIR" PLUGINS_DIR="$HOME/.config/omarchy/plugins" PLUGIN_ID="tank.clipboard" BINDINGS="$HOME/.config/hypr/bindings.conf" -echo "==> Symlinking plugin into $PLUGINS_DIR/$PLUGIN_ID" +echo "==> Linking plugin into $PLUGINS_DIR/$PLUGIN_ID" mkdir -p "$PLUGINS_DIR" ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID" +# On other machines, install directly from git instead: +# omarchy plugin add --enable chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh echo "==> Rescanning shell plugins" diff --git a/plugin/manifest.json b/manifest.json similarity index 100% rename from plugin/manifest.json rename to manifest.json diff --git a/plugin/open-entry.sh b/open-entry.sh similarity index 100% rename from plugin/open-entry.sh rename to open-entry.sh diff --git a/plugin/paste-entry.sh b/paste-entry.sh similarity index 100% rename from plugin/paste-entry.sh rename to paste-entry.sh diff --git a/plugin/__pycache__/capture.cpython-314.pyc b/plugin/__pycache__/capture.cpython-314.pyc deleted file mode 100644 index 101baf187b0c8cfea9c2f60c86f747031aafd218..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9426 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 KBIwnM;{0E>3vLhq diff --git a/tests/harness.mjs b/tests/harness.mjs index 4aeb615..b55ab5c 100644 --- a/tests/harness.mjs +++ b/tests/harness.mjs @@ -6,7 +6,7 @@ import path from "node:path" import vm from "node:vm" const testsDir = path.dirname(fileURLToPath(import.meta.url)) -const pluginDir = path.join(testsDir, "..", "plugin") +const pluginDir = path.join(testsDir, "..") export function loadLib(name) { let src = readFileSync(path.join(pluginDir, name), "utf8") diff --git a/tests/test-store.mjs b/tests/test-store.mjs index 3d014a5..a4a8199 100644 --- a/tests/test-store.mjs +++ b/tests/test-store.mjs @@ -110,3 +110,19 @@ test("entryId distinct for distinct content", () => { const b = Store.entryId({ type: "text", text: "two" }) assert.notEqual(a, b) }) + +test("qr payload passes through normalize and dedup", () => { + const e = Store.normalize({ type: "image", path: "/x/qr.png", mime: "image/png", w: 100, h: 100, qr: "https://example.com" }) + assert.equal(e.qr, "https://example.com") + // Re-copy of the same image keeps the decoded QR even if the new capture lacks it. + let h = Store.addEntry([], { type: "image", path: "/x/qr.png", qr: "https://example.com", ts: 1 }) + h = Store.addEntry(h, { type: "image", path: "/x/qr.png", ts: 2 }) + assert.equal(h.length, 1) + assert.equal(h[0].qr, "https://example.com") + assert.equal(h[0].ts, 2) +}) + +test("buildRow makes QR payload searchable", () => { + const row = Store.buildRow({ type: "image", path: "/x/qr.png", mime: "image/png", qr: "secret-payload-xyz", ts: 1, bytes: 10, app: "", uses: 0 }, "image", 1) + assert.ok(row.content.includes("secret-payload-xyz")) +})