DEV Community

Cover image for I Built a Clipboard Manager for Kali Linux — And Learned Way More Than Expected
freerave
freerave

Posted on

I Built a Clipboard Manager for Kali Linux — And Learned Way More Than Expected

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+V hotkey — shows the window from anywhere (Wayland-safe)
  • Settings panel, keyboard navigation, image viewer, video thumbnails

Screenshot of the main DotGhostBoard window with a few clipboard cards visible

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
Enter fullscreen mode Exit fullscreen mode

The problems:

  1. pynput runs a background keylogger that reads every keypress
  2. It's Wayland-incompatible — breaks on modern GNOME/KDE
  3. It consumes resources even when idle
  4. 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
Enter fullscreen mode Exit fullscreen mode

When you press Ctrl+Alt+V (registered as a system shortcut), the OS runs:

python3 main.py --show
Enter fullscreen mode Exit fullscreen mode

…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)
Enter fullscreen mode Exit fullscreen mode

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.

Diagram of the clipboard polling loop — QTimer → detect type → save → emit signal → Dashboard adds card


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
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Before: loading 50 image cards = 2-3 second freeze.
After: instant paint, thumbnails pop in one by one like a modern feed.

screenshot showing the loading placeholder → thumbnail transition


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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Three things I love about this design:

  1. If ffmpeg isn't installed → graceful fallback, no crash
  2. Runs in a background thread → UI never freezes
  3. The done signal updates the card live — the thumbnail just appears without any manual refresh

A video card before thumbnail (showing file path)

after (showing first frame)


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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode
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 ✅
Enter fullscreen mode Exit fullscreen mode

Terminal screenshot of pytest output showing 94 passed

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)