From 013c550047452c33f129954fe135f1ec41854f73 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 29 Jul 2026 15:26:23 +0200 Subject: [PATCH] feat: add clip-vault clipboard history daemon with webhook fanout --- clip_vault/__init__.py | 0 clip_vault/cli.py | 130 ++++++++++++++++++++++++++++++++ clip_vault/config.py | 29 +++++++ clip_vault/daemon.py | 166 +++++++++++++++++++++++++++++++++++++++++ clip_vault/storage.py | 100 +++++++++++++++++++++++++ clip_vault/webhook.py | 61 +++++++++++++++ config.default.json | 26 +++++++ install.sh | 37 +++++++++ 8 files changed, 549 insertions(+) create mode 100644 clip_vault/__init__.py create mode 100644 clip_vault/cli.py create mode 100644 clip_vault/config.py create mode 100644 clip_vault/daemon.py create mode 100644 clip_vault/storage.py create mode 100644 clip_vault/webhook.py create mode 100644 config.default.json create mode 100755 install.sh diff --git a/clip_vault/__init__.py b/clip_vault/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clip_vault/cli.py b/clip_vault/cli.py new file mode 100644 index 0000000..5d70fac --- /dev/null +++ b/clip_vault/cli.py @@ -0,0 +1,130 @@ +import argparse +import subprocess +import sys +import time + +from clip_vault import storage, webhook +from clip_vault.config import loadConfig, configPath, logPath + + +def formatRow(row): + clipId, content, sourceWindow, createdAt, pinned = row + preview = content.replace("\n", " ").strip() + if len(preview) > 80: + preview = preview[:77] + "..." + when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(createdAt)) + pinMark = "*" if pinned else " " + source = f" [{sourceWindow}]" if sourceWindow else "" + return f"{clipId:>5} {pinMark} {when}{source} {preview}" + + +def commandList(args): + rows = storage.listClips(limit=args.limit, search=args.search) + for row in rows: + print(formatRow(row)) + + +def commandShow(args): + row = storage.getClip(args.id) + if not row: + print(f"no clip with id {args.id}", file=sys.stderr) + sys.exit(1) + print(row[1]) + + +def commandCopy(args): + row = storage.getClip(args.id) + if not row: + print(f"no clip with id {args.id}", file=sys.stderr) + sys.exit(1) + subprocess.run(["xclip", "-selection", "clipboard"], input=row[1].encode("utf-8")) + print(f"copied clip {args.id} to clipboard") + + +def commandPin(args): + storage.setPinned(args.id, True) + print(f"pinned {args.id}") + + +def commandUnpin(args): + storage.setPinned(args.id, False) + print(f"unpinned {args.id}") + + +def commandDelete(args): + storage.deleteClip(args.id) + print(f"deleted {args.id}") + + +def commandClear(args): + storage.clearHistory(keepPinned=not args.all) + print("history cleared" + ("" if args.all else " (pinned items kept)")) + + +def commandConfig(args): + print(f"config: {configPath}") + print(f"log: {logPath}") + + +def commandTestWebhook(args): + config = loadConfig() + target = next((w for w in config.get("webhooks", []) if w.get("name") == args.name), None) + if not target: + print(f"no webhook named '{args.name}' in config", file=sys.stderr) + sys.exit(1) + ok = webhook.sendWebhook(target, args.content or "clip-vault test payload", "clip-vault-cli") + print("sent ok" if ok else "send failed, check " + logPath) + + +def buildParser(): + parser = argparse.ArgumentParser(prog="clip-vault", description="Clipboard history manager with configurable webhook forwarding") + subparsers = parser.add_subparsers(dest="command", required=True) + + listParser = subparsers.add_parser("list", help="show recent clipboard history") + listParser.add_argument("--limit", type=int, default=30) + listParser.add_argument("--search", type=str, default=None) + listParser.set_defaults(func=commandList) + + showParser = subparsers.add_parser("show", help="print full content of a clip") + showParser.add_argument("id", type=int) + showParser.set_defaults(func=commandShow) + + copyParser = subparsers.add_parser("copy", help="copy a stored clip back to the clipboard") + copyParser.add_argument("id", type=int) + copyParser.set_defaults(func=commandCopy) + + pinParser = subparsers.add_parser("pin", help="pin a clip so it survives trimming/clear") + pinParser.add_argument("id", type=int) + pinParser.set_defaults(func=commandPin) + + unpinParser = subparsers.add_parser("unpin", help="unpin a clip") + unpinParser.add_argument("id", type=int) + unpinParser.set_defaults(func=commandUnpin) + + deleteParser = subparsers.add_parser("delete", help="delete a clip") + deleteParser.add_argument("id", type=int) + deleteParser.set_defaults(func=commandDelete) + + clearParser = subparsers.add_parser("clear", help="clear history") + clearParser.add_argument("--all", action="store_true", help="also delete pinned clips") + clearParser.set_defaults(func=commandClear) + + configParser = subparsers.add_parser("config", help="show config and log file paths") + configParser.set_defaults(func=commandConfig) + + testWebhookParser = subparsers.add_parser("test-webhook", help="send a test payload through a configured webhook") + testWebhookParser.add_argument("name", type=str) + testWebhookParser.add_argument("--content", type=str, default=None) + testWebhookParser.set_defaults(func=commandTestWebhook) + + return parser + + +def main(): + parser = buildParser() + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/clip_vault/config.py b/clip_vault/config.py new file mode 100644 index 0000000..c0b26b0 --- /dev/null +++ b/clip_vault/config.py @@ -0,0 +1,29 @@ +import json +import os + +configDir = os.path.expanduser("~/.config/clip-vault") +configPath = os.path.join(configDir, "config.json") +defaultConfigPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.default.json") +dbPath = os.path.join(configDir, "history.sqlite3") +logPath = os.path.join(configDir, "webhook.log") + + +def loadConfig(): + if not os.path.exists(configPath): + installDefaultConfig() + with open(configPath) as configFile: + return json.load(configFile) + + +def installDefaultConfig(): + os.makedirs(configDir, exist_ok=True) + with open(defaultConfigPath) as defaultFile: + defaultContent = defaultFile.read() + with open(configPath, "w") as configFile: + configFile.write(defaultContent) + + +def saveConfig(config): + os.makedirs(configDir, exist_ok=True) + with open(configPath, "w") as configFile: + json.dump(config, configFile, indent=2) diff --git a/clip_vault/daemon.py b/clip_vault/daemon.py new file mode 100644 index 0000000..66f44fa --- /dev/null +++ b/clip_vault/daemon.py @@ -0,0 +1,166 @@ +import hashlib +import re +import subprocess + +import gi + +gi.require_version("Gtk", "3.0") +try: + gi.require_version("AyatanaAppIndicator3", "0.1") + from gi.repository import AyatanaAppIndicator3 as AppIndicator3 +except ValueError: + gi.require_version("AppIndicator3", "0.1") + from gi.repository import AppIndicator3 + +from gi.repository import GLib, Gtk + +from clip_vault import storage, webhook +from clip_vault.config import loadConfig + + +def activeWindowTitle(): + try: + output = subprocess.run( + ["xdotool", "getactivewindow", "getwindowname"], capture_output=True, text=True, timeout=1 + ) + return output.stdout.strip() if output.returncode == 0 else None + except Exception: + return None + + +class ClipVaultDaemon: + def __init__(self): + self.config = loadConfig() + self.paused = False + self.lastHash = None + from gi.repository import Gdk + self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + self.clipboard.connect("owner-change", self.onOwnerChange) + self.indicator = self.buildIndicator() + + def buildIndicator(self): + indicator = AppIndicator3.Indicator.new( + "clip-vault", "edit-copy-symbolic", AppIndicator3.IndicatorCategory.APPLICATION_STATUS + ) + indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) + indicator.set_menu(self.buildMenu()) + return indicator + + def buildMenu(self): + menu = Gtk.Menu() + + pauseItem = Gtk.CheckMenuItem(label="Pause capture") + pauseItem.set_active(self.paused) + pauseItem.connect("toggled", self.onTogglePause) + menu.append(pauseItem) + + menu.append(Gtk.SeparatorMenuItem()) + + recent = storage.listClips(limit=10) + if recent: + for clipId, content, sourceWindow, createdAt, pinned in recent: + preview = content.replace("\n", " ").strip() + if len(preview) > 60: + preview = preview[:57] + "..." + label = ("* " if pinned else "") + preview + item = Gtk.MenuItem(label=label or "(empty)") + item.connect("activate", self.onRecopy, content) + menu.append(item) + else: + emptyItem = Gtk.MenuItem(label="(no history yet)") + emptyItem.set_sensitive(False) + menu.append(emptyItem) + + menu.append(Gtk.SeparatorMenuItem()) + + reloadItem = Gtk.MenuItem(label="Reload config") + reloadItem.connect("activate", self.onReloadConfig) + menu.append(reloadItem) + + quitItem = Gtk.MenuItem(label="Quit") + quitItem.connect("activate", self.onQuit) + menu.append(quitItem) + + menu.show_all() + return menu + + def refreshMenu(self): + self.indicator.set_menu(self.buildMenu()) + + def onTogglePause(self, checkItem): + self.paused = checkItem.get_active() + + def onRecopy(self, _menuItem, content): + self.clipboard.set_text(content, -1) + self.clipboard.store() + + def onReloadConfig(self, _menuItem): + self.config = loadConfig() + + def onQuit(self, _menuItem): + Gtk.main_quit() + + def isIgnored(self, content, sourceWindow): + if sourceWindow: + for pattern in self.config.get("ignoreWindowPatterns", []): + if re.search(pattern, sourceWindow, re.IGNORECASE): + return True + for pattern in self.config.get("ignoreContentPatterns", []): + if re.search(pattern, content): + return True + return False + + def onOwnerChange(self, clipboard, _event): + if self.paused: + return + GLib.idle_add(self.captureCurrentClipboard) + + def captureCurrentClipboard(self): + text = self.clipboard.wait_for_text() + if not text: + return False + content = text + minLength = self.config.get("minContentLength", 2) + maxLength = self.config.get("maxContentLength", 200000) + if len(content) < minLength or len(content) > maxLength: + return False + + contentHash = hashlib.sha256(content.encode("utf-8")).hexdigest() + if contentHash == self.lastHash: + return False + self.lastHash = contentHash + + sourceWindow = activeWindowTitle() + if self.isIgnored(content, sourceWindow): + return False + + storage.addClip(content, contentHash, sourceWindow) + storage.trimHistory(self.config.get("maxHistory", 500)) + + def onWebhookDone(sentNames): + if sentNames: + GLib.idle_add(self.markWebhookSent, contentHash, sentNames) + + webhook.dispatchAsync(content, sourceWindow, self.config, onDone=onWebhookDone) + + GLib.idle_add(self.refreshMenu) + return False + + def markWebhookSent(self, contentHash, sentNames): + connection = storage.connect() + connection.execute( + "UPDATE clips SET webhookSentTo = ? WHERE id = (SELECT id FROM clips WHERE contentHash = ? ORDER BY id DESC LIMIT 1)", + (",".join(sentNames), contentHash), + ) + connection.commit() + connection.close() + return False + + +def main(): + daemon = ClipVaultDaemon() + Gtk.main() + + +if __name__ == "__main__": + main() diff --git a/clip_vault/storage.py b/clip_vault/storage.py new file mode 100644 index 0000000..670394d --- /dev/null +++ b/clip_vault/storage.py @@ -0,0 +1,100 @@ +import os +import sqlite3 +import time + +from clip_vault.config import configDir, dbPath + + +def connect(): + os.makedirs(configDir, exist_ok=True) + connection = sqlite3.connect(dbPath) + connection.execute(""" + CREATE TABLE IF NOT EXISTS clips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + contentHash TEXT NOT NULL, + sourceWindow TEXT, + createdAt REAL NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + webhookSentTo TEXT + ) + """) + connection.execute("CREATE INDEX IF NOT EXISTS idxContentHash ON clips(contentHash)") + return connection + + +def addClip(content, contentHash, sourceWindow, webhookSentTo=None): + connection = connect() + existing = connection.execute( + "SELECT id FROM clips WHERE contentHash = ? ORDER BY id DESC LIMIT 1", (contentHash,) + ).fetchone() + if existing: + connection.execute("DELETE FROM clips WHERE id = ?", (existing[0],)) + connection.execute( + "INSERT INTO clips (content, contentHash, sourceWindow, createdAt, pinned, webhookSentTo) VALUES (?, ?, ?, ?, 0, ?)", + (content, contentHash, sourceWindow, time.time(), webhookSentTo), + ) + connection.commit() + connection.close() + + +def trimHistory(maxHistory): + connection = connect() + connection.execute( + """ + DELETE FROM clips WHERE id IN ( + SELECT id FROM clips WHERE pinned = 0 + ORDER BY createdAt DESC LIMIT -1 OFFSET ? + ) + """, + (maxHistory,), + ) + connection.commit() + connection.close() + + +def listClips(limit=50, search=None): + connection = connect() + if search: + rows = connection.execute( + "SELECT id, content, sourceWindow, createdAt, pinned FROM clips WHERE content LIKE ? ORDER BY createdAt DESC LIMIT ?", + (f"%{search}%", limit), + ).fetchall() + else: + rows = connection.execute( + "SELECT id, content, sourceWindow, createdAt, pinned FROM clips ORDER BY createdAt DESC LIMIT ?", + (limit,), + ).fetchall() + connection.close() + return rows + + +def getClip(clipId): + connection = connect() + row = connection.execute("SELECT id, content, sourceWindow, createdAt, pinned FROM clips WHERE id = ?", (clipId,)).fetchone() + connection.close() + return row + + +def setPinned(clipId, pinned): + connection = connect() + connection.execute("UPDATE clips SET pinned = ? WHERE id = ?", (1 if pinned else 0, clipId)) + connection.commit() + connection.close() + + +def deleteClip(clipId): + connection = connect() + connection.execute("DELETE FROM clips WHERE id = ?", (clipId,)) + connection.commit() + connection.close() + + +def clearHistory(keepPinned=True): + connection = connect() + if keepPinned: + connection.execute("DELETE FROM clips WHERE pinned = 0") + else: + connection.execute("DELETE FROM clips") + connection.commit() + connection.close() diff --git a/clip_vault/webhook.py b/clip_vault/webhook.py new file mode 100644 index 0000000..afebabc --- /dev/null +++ b/clip_vault/webhook.py @@ -0,0 +1,61 @@ +import json +import re +import threading +import time +import urllib.request + +from clip_vault.config import logPath + + +def logLine(line): + with open(logPath, "a") as logFile: + logFile.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {line}\n") + + +def matchingWebhooks(content, config): + matches = [] + for webhook in config.get("webhooks", []): + if not webhook.get("enabled"): + continue + pattern = webhook.get("matchPattern", ".*") + try: + if re.search(pattern, content): + matches.append(webhook) + except re.error as regexError: + logLine(f"invalid matchPattern in webhook '{webhook.get('name')}': {regexError}") + return matches + + +def sendWebhook(webhook, content, sourceWindow): + body = {"content": content, "timestamp": time.time()} + if webhook.get("includeSourceWindow", True): + body["sourceWindow"] = sourceWindow + data = json.dumps(body).encode("utf-8") + headers = {"Content-Type": "application/json"} + headers.update(webhook.get("headers", {})) + request = urllib.request.Request(webhook["url"], data=data, headers=headers, method=webhook.get("method", "POST")) + try: + with urllib.request.urlopen(request, timeout=webhook.get("timeoutSeconds", 4)) as response: + logLine(f"webhook '{webhook.get('name')}' -> {response.status}") + return True + except Exception as sendError: + logLine(f"webhook '{webhook.get('name')}' failed: {sendError}") + return False + + +def dispatchAsync(content, sourceWindow, config, onDone=None): + matches = matchingWebhooks(content, config) + if not matches: + if onDone: + onDone([]) + return + + def worker(): + sentNames = [] + for webhook in matches: + if sendWebhook(webhook, content, sourceWindow): + sentNames.append(webhook.get("name", webhook["url"])) + if onDone: + onDone(sentNames) + + threading.Thread(target=worker, daemon=True).start() diff --git a/config.default.json b/config.default.json new file mode 100644 index 0000000..f25984a --- /dev/null +++ b/config.default.json @@ -0,0 +1,26 @@ +{ + "maxHistory": 500, + "minContentLength": 2, + "maxContentLength": 200000, + "pollFallbackSeconds": 2, + "ignoreWindowPatterns": [ + "KeePassXC", + "Bitwarden", + "1Password" + ], + "ignoreContentPatterns": [ + "-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----" + ], + "webhooks": [ + { + "name": "example-disabled", + "enabled": false, + "url": "https://example.com/webhook", + "method": "POST", + "matchPattern": ".*", + "headers": {}, + "timeoutSeconds": 4, + "includeSourceWindow": true + } + ] +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..01629db --- /dev/null +++ b/install.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +sourceDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +installDir="$HOME/.local/share/clip-vault" +binDir="$HOME/.local/bin" +autostartDir="$HOME/.config/autostart" + +mkdir -p "$installDir" "$binDir" "$autostartDir" +rsync -a --delete "$sourceDir/clip_vault" "$installDir/" +cp "$sourceDir/config.default.json" "$installDir/config.default.json" + +cat > "$binDir/clip-vault" < "$binDir/clip-vault-daemon" < "$autostartDir/clip-vault.desktop" <