QR decode support (zbarimg) with searchable payload + preview panel; restructure repo root as installable omarchy plugin; plain-text safety for QR payloads

This commit is contained in:
2026-09-05 16:22:10 +01:00
parent 63a003e4a5
commit 1aac582e90
15 changed files with 144 additions and 16 deletions
+1
View File
@@ -1 +1,2 @@
*.bak *.bak
__pycache__/
View File
+10 -3
View File
@@ -794,9 +794,15 @@ Item {
var subtitleParts = [] var subtitleParts = []
if (e.type === "image") { if (e.type === "image") {
titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "") if (e.qr) {
if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h titleHtml = Fuzzy.escapeHtml(Classify.firstLine(e.qr, 60))
subtitleParts.push(Classify.formatBytes(r.row.bytes)) 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") { } else if (e.type === "files") {
var base = Classify.fileBase(e.paths[0]) var base = Classify.fileBase(e.paths[0])
titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base) titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base)
@@ -808,6 +814,7 @@ Item {
} }
subtitleParts.push(Classify.typeLabel(derived)) 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.app) subtitleParts.push(Classify.prettyApp(r.row.app))
if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000))) if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000)))
View File
+64 -2
View File
@@ -57,6 +57,7 @@ Item {
var e = r.entry var e = r.entry
var chips = [] var chips = []
if (r.app) chips.push(Classify.prettyApp(r.app)) 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) { if (e.type === "text" && e.text) {
var st = Classify.textStats(e.text) var st = Classify.textStats(e.text)
chips.push(Classify.plural(st.words, "word")) chips.push(Classify.plural(st.words, "word"))
@@ -105,7 +106,7 @@ Item {
text: { text: {
if (!root.entry) return "" if (!root.entry) return ""
var e = root.entry 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") { if (e.type === "files") {
var base = Classify.fileBase(e.paths[0]) var base = Classify.fileBase(e.paths[0])
return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base
@@ -116,6 +117,7 @@ Item {
font.family: root.font_ font.family: root.font_
font.pixelSize: Style.font.title font.pixelSize: Style.font.title
font.bold: true font.bold: true
textFormat: Text.PlainText
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
} }
@@ -303,9 +305,68 @@ Item {
visible: root.derived === "image" visible: root.derived === "image"
spacing: Style.space(8) 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 { Item {
width: parent.width 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 { Image {
id: img id: img
@@ -474,6 +535,7 @@ Item {
id: chipLabel id: chipLabel
anchors.centerIn: parent anchors.centerIn: parent
text: parent.chipText text: parent.chipText
textFormat: Text.PlainText
color: root.mutedFg color: root.mutedFg
font.family: root.font_ font.family: root.font_
font.pixelSize: Style.font.caption font.pixelSize: Style.font.caption
+22 -7
View File
@@ -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. type, byte size, source app (via `hyprctl`), timestamp, and image dimensions.
Text, images, and `file://` URI lists (file-manager copies) are supported; Text, images, and `file://` URI lists (file-manager copies) are supported;
password-manager clips and binary payloads are skipped. 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 - **Fuzzy search over everything** — fzf-style scoring over content, source
app, and type, plus recency, pin, and usage boosts. Query tokens: app, and type, plus recency, pin, and usage boosts. Query tokens:
- `type:image|link|text|files|code|json|color|email|html|number` (prefix match) - `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 ./install.sh
``` ```
This symlinks `plugin/` into `~/.config/omarchy/plugins/tank.clipboard`, This symlinks the repo (the plugin root) into
rescans + enables the plugin (the built-in `omarchy.clipboard` is replaced — `~/.config/omarchy/plugins/tank.clipboard`, rescans + enables the plugin (the
revert with `omarchy plugin disable tank.clipboard`), and rebinds built-in `omarchy.clipboard` is replaced — revert with
`ALT+SHIFT+V` from vicinae to this picker (backing up `bindings.conf`). `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 <this-repo-git-url> --enable
```
Note: the local dev symlink trips `omarchy plugin validate` (it refuses
symlinked plugin folders); a git-cloned copy validates clean.
## Layout ## Layout
The repo root *is* the plugin folder (so `omarchy plugin add <git-url>` works
directly — it validates and clones a repo whose root holds `manifest.json`).
``` ```
plugin/
├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard) ├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard)
├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers ├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers
├── PreviewPane.qml # per-type preview + metadata chips ├── PreviewPane.qml # per-type preview + metadata chips
├── Store.js # history model: dedup, pins, pruning, ids ├── Store.js # history model: dedup, pins, pruning, ids
├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting ├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting
├── Classify.js # type detection, app names, formatting, color math ├── 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 ├── paste-entry.sh # copy + shift-insert paste into the focused window
└── open-entry.sh # open with the right app └── open-entry.sh # open with the right app
@@ -73,7 +88,7 @@ History is stored in `~/.local/state/omarchy/clipboard-history-rich.json`
## Development ## 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` - 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). (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 - Data lives in `~/.local/state/omarchy/`; the picker state is independent of
+4
View File
@@ -50,6 +50,7 @@ function normalize(value, now) {
} }
if (value.w) out.w = Number(value.w) if (value.w) out.w = Number(value.w)
if (value.h) out.h = Number(value.h) if (value.h) out.h = Number(value.h)
if (value.qr) out.qr = String(value.qr)
} else if (type === "files") { } else if (type === "files") {
var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : [] var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : []
if (paths.length === 0) return null 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. // Re-copy of existing content: keep its pin state and usage count.
if (existing.pinned) normalized.pinned = true if (existing.pinned) normalized.pinned = true
if (existing.uses > 0) normalized.uses = existing.uses 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 continue
} }
next.push(existing) next.push(existing)
@@ -176,6 +179,7 @@ function buildRow(entry, derivedType, now) {
var content = "" var content = ""
if (entry.type === "image") { if (entry.type === "image") {
content = fileLabel(entry.path) + " " + String(entry.mime || "") content = fileLabel(entry.path) + " " + String(entry.mime || "")
if (entry.qr) content += " " + String(entry.qr).slice(0, 500)
} else if (entry.type === "files") { } else if (entry.type === "files") {
content = (entry.paths || []).join(" ") content = (entry.paths || []).join(" ")
} else { } else {
+21
View File
@@ -65,6 +65,22 @@ def emit(entry):
sys.stdout.flush() 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): def capture_image(types, app):
mime = next((m for m in IMAGE_MIMES if m in types), None) mime = next((m for m in IMAGE_MIMES if m in types), None)
if not mime: if not mime:
@@ -98,6 +114,11 @@ def capture_image(types, app):
except OSError: except OSError:
pass pass
return return
qr = decode_qr(path)
if qr:
entry["qr"] = qr
emit(entry) emit(entry)
return True return True
+5 -3
View File
@@ -1,21 +1,23 @@
#!/bin/bash #!/bin/bash
# Install the tank.clipboard Omarchy shell plugin from this repo. # 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 # - rescans + enables it (shell.json gets plugins[] entry; the built-in
# omarchy.clipboard is recorded in disabledPlugins[] and routed here) # omarchy.clipboard is recorded in disabledPlugins[] and routed here)
# - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf) # - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf)
set -euo pipefail set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="$REPO_DIR/plugin" PLUGIN_DIR="$REPO_DIR"
PLUGINS_DIR="$HOME/.config/omarchy/plugins" PLUGINS_DIR="$HOME/.config/omarchy/plugins"
PLUGIN_ID="tank.clipboard" PLUGIN_ID="tank.clipboard"
BINDINGS="$HOME/.config/hypr/bindings.conf" 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" mkdir -p "$PLUGINS_DIR"
ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID" ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID"
# On other machines, install directly from git instead:
# omarchy plugin add <this-repo-git-url> --enable
chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh
echo "==> Rescanning shell plugins" echo "==> Rescanning shell plugins"
Binary file not shown.
+1 -1
View File
@@ -6,7 +6,7 @@ import path from "node:path"
import vm from "node:vm" import vm from "node:vm"
const testsDir = path.dirname(fileURLToPath(import.meta.url)) const testsDir = path.dirname(fileURLToPath(import.meta.url))
const pluginDir = path.join(testsDir, "..", "plugin") const pluginDir = path.join(testsDir, "..")
export function loadLib(name) { export function loadLib(name) {
let src = readFileSync(path.join(pluginDir, name), "utf8") let src = readFileSync(path.join(pluginDir, name), "utf8")
+16
View File
@@ -110,3 +110,19 @@ test("entryId distinct for distinct content", () => {
const b = Store.entryId({ type: "text", text: "two" }) const b = Store.entryId({ type: "text", text: "two" })
assert.notEqual(a, b) 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"))
})