So here's how it started: I kept losing code snippets, IP addresses, and payloads between windows during a pentest session. CopyQ felt too heavy. xclip has no UI. I decided to build my own.
What I expected: a weekend project.
What I got: a full-blown v1.2.0 release with a settings panel, video thumbnails, drag-and-drop reordering, and 94 passing tests.
This is the story of DotGhostBoard 👻
What Is It?
A clipboard manager for Linux (built for Kali, works everywhere with PyQt6 and a dark neon soul).
Core features:
- Captures text, images, and video file paths automatically
- Persistent SQLite storage — survives reboots
- Pin system — important items can never be deleted
- System tray — lives quietly in the background
-
Ctrl+Alt+Vhotkey — shows the window from anywhere (Wayland-safe) - Settings panel, keyboard navigation, image viewer, video thumbnails
The Architecture Decision That Changed Everything
The original plan had a pynput-based global hotkey listener:
# ❌ The bad version — DON'T do this
from pynput import keyboard
class HotkeyListener:
def __init__(self, callback):
self._hotkey = keyboard.HotKey(
keyboard.HotKey.parse('<ctrl>+<alt>+v'),
callback
)
self._listener = keyboard.Listener(
on_press=self._on_press,
on_release=self._on_release
)
def start(self):
self._listener.start() # This is basically a keylogger
The problems:
-
pynputruns a background keylogger that reads every keypress - It's Wayland-incompatible — breaks on modern GNOME/KDE
- It consumes resources even when idle
- It's conceptually wrong — we don't need to monitor the keyboard globally
The fix: use QLocalServer — PyQt6's IPC mechanism.
# ✅ The right way — core/main.py
from PyQt6.QtNetwork import QLocalServer, QLocalSocket
def _setup_ipc(app, dashboard):
server = QLocalServer()
server.listen("DotGhostBoard_IPC")
def _on_new_connection():
conn = server.nextPendingConnection()
conn.waitForReadyRead(300)
msg = bytes(conn.readAll()).decode(errors="ignore").strip()
if msg == "SHOW":
dashboard.show_and_raise()
conn.disconnectFromServer()
server.newConnection.connect(_on_new_connection)
return server
When you press Ctrl+Alt+V (registered as a system shortcut), the OS runs:
python3 main.py --show
…which launches a second instance, sends b"SHOW" to the IPC socket, and exits immediately. No keylogger. No root. No Wayland issues.
The Clipboard Watcher
The watcher is a QObject with a QTimer polling every 500ms. The key insight was using a content signature instead of calling .tobytes():
# core/watcher.py
elif content_type == "image":
qimage = self._clipboard.image()
if qimage is None or qimage.isNull():
return
# ✅ Safe: dimensional signature — no raw bytes
# ❌ NOT: qimage.bits().tobytes() — causes IOT instruction / SIGABRT
img_sig = f"{qimage.width()}x{qimage.height()}_{qimage.sizeInBytes()}"
if img_sig != self._last_content:
self._last_content = img_sig
file_path = media.save_image_from_qimage(qimage)
if file_path:
item_id = storage.add_item("image", file_path, preview=file_path)
self.new_image_captured.emit(item_id, file_path)
The qimage.bits().tobytes() call was causing a hard crash (Illegal instruction (core dumped)) on PyQt6 6.6+. The dimensional signature avoids that entirely and is collision-resistant enough for clipboard deduplication.
The Storage Layer
SQLite with a context manager to guarantee connection cleanup:
# core/storage.py
from contextlib import contextmanager
@contextmanager
def _db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close() # Always closes, even on exception
The schema — notice the sort_order column added in v1.2.0 for drag-and-drop reordering, and the preview column for thumbnails:
CREATE TABLE IF NOT EXISTS clipboard_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, -- 'text' | 'image' | 'video'
content TEXT NOT NULL, -- text or file path
preview TEXT DEFAULT NULL, -- thumbnail path
is_pinned INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0, -- for drag-and-drop (v1.2.0)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
Adding a new column to an existing database without breaking it:
def init_db():
with _db() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS clipboard_items (...)")
# Safe migration — silently skips if column exists
try:
conn.execute(
"ALTER TABLE clipboard_items ADD COLUMN sort_order INTEGER DEFAULT 0"
)
except Exception:
pass
Lazy Image Loading (v1.2.0)
The original version loaded images synchronously inside _build_content(). This froze the UI when loading a history of 200 items.
The fix: defer thumbnail loading with QTimer.singleShot(0, ...). This yields control back to the Qt event loop, paints the card first, then loads the pixel data.
# ui/widgets.py
def _build_content(self, item: dict):
if item["type"] == "image":
# Show placeholder immediately
self._img_label = QLabel("🖼 Loading…")
self._img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._img_label.mousePressEvent = self._on_image_click # S004
# Defer actual disk read to after paint
QTimer.singleShot(0, self._load_thumbnail)
return self._img_label
def _load_thumbnail(self):
path = self._preview or self._file_path
pixmap = QPixmap(path)
if pixmap.isNull():
self._img_label.setText("⚠ Invalid image")
return
# Cap at 300×180px, preserve aspect ratio
if pixmap.width() > 300:
pixmap = pixmap.scaledToWidth(300, Qt.TransformationMode.SmoothTransformation)
if pixmap.height() > 180:
pixmap = pixmap.scaledToHeight(180, Qt.TransformationMode.SmoothTransformation)
self._img_label.setPixmap(pixmap)
Before: loading 50 image cards = 2-3 second freeze.
After: instant paint, thumbnails pop in one by one like a modern feed.
Video Thumbnails via ffmpeg (v1.2.0)
When a video file path is copied, we extract the first frame using a background QThread — never blocking the UI:
# core/thumbnailer.py
def extract_video_thumb(video_path: str, item_id: int) -> str | None:
out_path = os.path.join(THUMB_DIR, f"{item_id}.png")
try:
result = subprocess.run(
["ffmpeg", "-ss", "0", "-i", video_path,
"-frames:v", "1", "-vf", "scale=300:-1", out_path, "-y"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
)
if result.returncode == 0 and os.path.isfile(out_path):
return out_path
except FileNotFoundError:
print("[Thumbnailer] ffmpeg not found — video thumbnails disabled")
except subprocess.TimeoutExpired:
print(f"[Thumbnailer] Timeout for: {video_path}")
return None
The QThread that wraps it:
# core/watcher.py
class _ThumbWorker(QThread):
done = pyqtSignal(int, str) # (item_id, thumb_path)
def run(self):
thumb = extract_video_thumb(self._video_path, self._item_id)
if thumb:
self.done.emit(self._item_id, thumb)
Three things I love about this design:
- If ffmpeg isn't installed → graceful fallback, no crash
- Runs in a background thread → UI never freezes
- The done signal updates the card live — the thumbnail just appears without any manual refresh
Drag & Drop for Pinned Cards (v1.2.0)
Pinned cards get a ⠿ drag handle and use QDrag with a custom MIME type carrying the item_id:
# ui/widgets.py
def _do_drag(self):
drag = QDrag(self)
mime = QMimeData()
mime.setData(
"application/x-dotghost-card-id",
QByteArray(str(self.item_id).encode())
)
drag.setMimeData(mime)
drag.exec(Qt.DropAction.MoveAction)
On drop in dashboard.py, we rebuild sort_order for all pinned cards in their new visual order and persist it:
# ui/dashboard.py
def _drop_event(self, event):
dragged_id = int(
event.mimeData().data("application/x-dotghost-card-id").data().decode()
)
# ... find target card at drop position ...
pinned_cards.remove(dragged_card)
pinned_cards.insert(target_idx, dragged_card)
# Persist new order
for order, card in enumerate(pinned_cards):
storage.update_sort_order(card.item_id, order)
# Re-insert in new visual order
for order, card in enumerate(pinned_cards):
layout.insertWidget(order, card)
The Test Suite
94 tests, 0.26s.
The trick to testing storage without touching the real database:
# tests/test_storage.py
import tempfile
import core.storage as storage
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
storage.DB_PATH = _tmp.name # Redirect before any test runs
@pytest.fixture(autouse=True)
def fresh_db():
storage.init_db()
yield
with storage._db() as conn:
conn.execute("DELETE FROM clipboard_items")
Testing ffmpeg graceful failure without actually needing ffmpeg:
# tests/test_thumbnailer.py
def test_returns_none_when_ffmpeg_absent(self, monkeypatch):
def fake_run(*args, **kwargs):
raise FileNotFoundError("ffmpeg not found")
monkeypatch.setattr(subprocess, "run", fake_run)
result = thumbnailer.extract_video_thumb(fake_video, item_id=3)
assert result is None # Must not crash!
tests/test_media.py 27 passed
tests/test_settings.py 11 passed
tests/test_storage.py 32 passed
tests/test_storage_v120.py 17 passed
tests/test_thumbnailer.py 8 passed
─────────────────────────────────────
TOTAL 94 passed in 0.26s ✅
The Stylesheet
The whole UI runs on a single ghost.qss file. QSS supports property selectors, which makes state-based styling elegant:
/* Base card */
QFrame#ItemCard {
background-color: #141414;
border: 1px solid #222;
border-radius: 8px;
}
/* Pinned = gold border */
QFrame#ItemCard[pinned="true"] {
border: 1px solid #ffcc00;
background-color: #1a1800;
}
/* Keyboard focused = neon green border */
QFrame#ItemCard[focused="true"] {
border: 1px solid #00ff41;
background-color: #0d1f0d;
}
/* Both pinned AND focused */
QFrame#ItemCard[pinned="true"][focused="true"] {
border: 1px solid #ffcc00;
background-color: #1f1e00;
}
Triggering a state change from Python:
def set_focused(self, focused: bool):
self.setProperty("focused", str(focused).lower())
self.style().unpolish(self) # Force Qt to re-read the property
self.style().polish(self)
What I Actually Learned
1. pynput is the wrong tool for app hotkeys.
Use your desktop environment's native shortcut system + IPC. It's simpler, safer, and works on Wayland.
2. QTimer.singleShot(0, fn) is magic for deferred work.
It doesn't mean "run in 0ms" — it means "yield to the event loop, then run." Perfect for lazy loading.
3. Raw bytes from Qt image objects can cause hard crashes.
PyQt6 6.6+ changed memory ownership for QImage.bits(). Use dimensional signatures for deduplication instead.
4. Background threads in Qt need QThread, not threading.Thread.
QThread integrates with the signal/slot system, so you can safely emit signals from a background thread to update the UI.
5. SQLite migrations in Python are two lines.
Just try the ALTER TABLE, catch the exception when the column already exists. Simple and battle-tested.
What's Next (v1.3.0 "Wraith")
- Tags and collections
- Multi-select + bulk delete
- Export to JSON/Markdown
- AES-256 encryption coming in v1.4.0
Try It
git clone https://github.com/kareem2099/DotGhostBoard
cd DotGhostBoard
pip install -r requirements.txt
python3 scripts/generate_icon.py
bash scripts/install.sh
python3 main.py
Or just use Ctrl+Alt+V once the installer sets up the shortcut.
Built with PyQt6, SQLite, ffmpeg, and too many late-night commits.
If this was useful, drop a ❤️ or leave a comment — especially if you're building something similar for your own workflow.




Top comments (4)
Nice project 👌 always cool to see someone build something out of a real need.
Clipboard management on Linux is way more annoying than it should be 😅 curious how you’re handling duplicates or sensitive stuff?
Thanks a lot! Yeah, the Linux clipboard ecosystem can definitely be a wild ride, so I built this to solve my own workflow pain points.
To answer your questions:
For duplicates: DotGhostBoard handles this directly at the SQLite database level. Before any new clip is inserted, the backend checks for existing content. If it's a duplicate, it just ignores the new insert to keep the DB lightweight and clean.
For sensitive data: You nailed it, that's a huge priority. Right now, everything is strictly local (~/.config/dotghostboard/) with zero telemetry. But looking at the roadmap, v1.4.0 will introduce app exclusions (blacklisting password managers) and AES-256 encryption for secret items. Furthermore, in the 2.x roadmap, I plan to integrate native token, secret, and password generators, plus packet saving!
You can check out the full 1.x roadmap on the GitHub repo. Thanks for checking it out!
It's interesting how often those "weekend projects" balloon into something much larger once you start peeling back the layers. The initial simplicity can be deceptive, especially when you start adding quality-of-life features like video thumbnails and robust testing. I know that feeling all too well!
Spot on! It’s the 'just one more feature' trap. What started as a simple clipboard monitor turned into a lesson in IPC, SQLite migrations, and asynchronous thumbnailing. But honestly, that's where the real fun (and the best learning) happens. Stay tuned for v1.4.0 'Eclipse'—I've spent the last few days peeling back even deeper layers regarding security and encryption. The rabbit hole goes deep!