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 (0)