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

Collapse
 
ai_made_tools profile image
Joske Vermeulen

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?

Collapse
 
freerave profile image
freerave

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!

Collapse
 
botanica_andina profile image
Botánica Andina

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!

Collapse
 
freerave profile image
freerave

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!