DEV Community

Cover image for Building DotGhostBoard v1.5.5: Spotlight Search, SHA-256 Image Deduplication & Zero-Trust Clipboard Security in PyQt6
freerave
freerave

Posted on

Building DotGhostBoard v1.5.5: Spotlight Search, SHA-256 Image Deduplication & Zero-Trust Clipboard Security in PyQt6

How we built a Kali Linux-native, privacy-first clipboard manager featuring a floating Spotlight search overlay (Ctrl+Shift+F), smart image hashing, search input debouncing, and Master Password security.

DotGhostBoard v1.5.5 Hero Dashboard

I was working on Kali Linux, taking multiple screenshot captures of terminal error tracebacks and copying temporary API bearer tokens, when I realized two frustrating things:

  1. My disk was getting littered with dozens of duplicate PNG screenshots (capture_1427.png, capture_1428.png) taking up space and cluttering my feed.
  2. The clipboard managers available on Linux were either heavy Electron apps devouring 350MB+ RAM just sitting in the tray, or basic plain-text loggers that stored sensitive API keys unencrypted on disk.

That's why we built DotGhostBoard โ€” a Kali Linux-native, privacy-first, zero-trust clipboard manager built with Python 3 and PyQt6. It features AES-256-GCM encryption, local SQLite storage, global hotkey overlays, and a dark neon aesthetic โ€” using ~85 MB of Full Runtime RAM.

In this article, we'll dive deep into the architecture, design choices, and actual Python code behind the new features introduced in v1.5.5 (Nexus Polish & Spotlight).


๐Ÿš€ Key Features Introduced in v1.5.5

Here is what we engineered for this release:

  • ๐Ÿ” Spotlight Quick Search Overlay (ui/spotlight.py): A frameless, floating quick-search popup accessible globally via Ctrl+Shift+F.
  • ๐Ÿ–ผ๏ธ Smart Image SHA-256 Deduplication (core/storage.py): Automatic pixel hashing that prevents duplicate screenshots from cluttering disk space or UI feeds.
  • ๐Ÿ” Master Password Protection for Secret Copying (ui/dashboard.py): Zero-leak in-memory decryption for encrypted clipboard items.
  • โšก Search Input Debouncing (QTimer): 150msโ€“200ms non-blocking debounce timers to eliminate UI lag during rapid typing.
  • ๐Ÿ“Š Dashboard Stats Header & Relative Time Refresh: Real-time state summary banner and relative timestamps ("just now", "5m ago", "2h ago").

1. Spotlight Quick Search Overlay (ui/spotlight.py)

A core goal of v1.5.5 was allowing users to query their clipboard history from any application without switching windows manually.

Spotlight Quick Search Overlay (Ctrl+Shift+F)

We designed SpotlightSearchDialog using PyQt6's QDialog with frameless window hints and active screen auto-centering:

๐Ÿ”— GitHub Source Code: ui/spotlight.py

from PyQt6.QtWidgets import (
    QDialog, QVBoxLayout, QLineEdit, QListWidget,
    QListWidgetItem, QLabel, QHBoxLayout, QFrame,
    QApplication, QGraphicsDropShadowEffect
)
from PyQt6.QtCore import Qt, pyqtSignal, QEvent, QTimer
from PyQt6.QtGui import QColor, QKeyEvent

from core import storage


class SpotlightSearchDialog(QDialog):
    """
    Spotlight-style floating search overlay.
    - Frameless, stays on top, centered on active monitor.
    - Up / Down / Enter keyboard navigation.
    - Emits sig_item_selected(item) when user picks an item.
    """
    sig_item_selected = pyqtSignal(dict)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowFlags(
            Qt.WindowType.FramelessWindowHint |
            Qt.WindowType.WindowStaysOnTopHint |
            Qt.WindowType.Tool
        )
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
        self.setFixedWidth(640)
        self.setFixedHeight(420)

        self._items = []
        self._pending_query = ""

        # 150ms search debounce timer
        self._search_timer = QTimer(self)
        self._search_timer.setSingleShot(True)
        self._search_timer.setInterval(150)
        self._search_timer.timeout.connect(self._do_search)

        self._build_ui()
        self._center_on_screen()
Enter fullscreen mode Exit fullscreen mode

Keyboard Navigation & Focus Loss

To make Spotlight feel as smooth as macOS Spotlight or Raycast:

  1. eventFilter captures Up, Down, Enter, and Esc keys regardless of whether focus is on the QLineEdit or QListWidget.
  2. Encrypted secret items (is_secret == 1) are masked with ๐Ÿ”’ Secret Item (Encrypted) to prevent sensitive data leaks.
  3. changeEvent listens for ActivationChange to automatically hide Spotlight when the user clicks outside.
    def eventFilter(self, obj, event):
        if event.type() == QEvent.Type.KeyPress:
            key_event: QKeyEvent = event
            key = key_event.key()

            if key == Qt.Key.Key_Escape:
                self.hide()
                return True

            elif key == Qt.Key.Key_Down:
                cur = self.results_list.currentRow()
                if cur < self.results_list.count() - 1:
                    self.results_list.setCurrentRow(cur + 1)
                return True

            elif key == Qt.Key.Key_Up:
                cur = self.results_list.currentRow()
                if cur > 0:
                    self.results_list.setCurrentRow(cur - 1)
                return True

            elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
                current_item = self.results_list.currentItem()
                if current_item:
                    self._on_item_activated(current_item)
                return True

        return super().eventFilter(obj, event)

    def changeEvent(self, event):
        if event.type() == QEvent.Type.ActivationChange:
            if not self.isActiveWindow():
                self.hide()
        super().changeEvent(event)
Enter fullscreen mode Exit fullscreen mode

2. Smart Image Deduplication via SHA-256 (core/storage.py)

The Problem

When capturing screenshots or images from the system clipboard, QImage.save() creates a new temporary PNG file (e.g., capture_20260805_142748.png). Because each file gets a timestamped path, traditional string matching (WHERE content = ?) fails to recognize identical images. This led to duplicate cards filling the dashboard and wasting disk space.

The Solution: SHA-256 Image Hashing

Image SHA-256 Deduplication Feed

Inside storage.add_item(), when an image is saved:

  1. Compute the SHA-256 checksum of the new PNG file.
  2. Query existing image rows in SQLite.
  3. If an existing image has the exact same SHA-256 hash:
    • Purge the newly generated temporary PNG file from disk (os.remove()).
    • Execute UPDATE clipboard_items SET updated_at = NOW, copy_count = copy_count + 1 WHERE id = ?.
    • Return the existing item ID so the UI floats the original image card to the top with an updated frequency badge (ร—2, ร—3, ๐Ÿ”ฅ ร—10)!

๐Ÿ”— GitHub Source Code: core/storage.py

import hashlib

def _get_file_hash(filepath: str) -> str | None:
    """Calculate SHA-256 hash of a file for image duplicate detection."""
    if not filepath or not os.path.isfile(filepath):
        return None
    try:
        h = hashlib.sha256()
        with open(filepath, "rb") as f:
            for chunk in iter(lambda: f.read(65536), b""):
                h.update(chunk)
        return h.hexdigest()
    except OSError:
        return None


def add_item(item_type: str, content: str, preview: str = None) -> int:
    """
    Add a new item to the database using a 3-stage cascade for optimal performance:
    - Stage 1: Fast O(1) string match (avoids unnecessary disk IO).
    - Stage 2: SHA-256 pixel hash match for images (resolves timestamped file duplicates).
    - Stage 3: Clean SQL insertion for brand new unique items.
    """
    # โ”€โ”€ STAGE 1: Direct Content Match โ”€โ”€
    # Check text strings or identical paths first. This is an immediate SQL lookup,
    # avoiding heavy disk reads if the exact same text or path was already recorded.
    existing = get_item_by_content(content)
    if existing:
        now = datetime.now().isoformat()
        with _db() as conn:
            conn.execute(
                "UPDATE clipboard_items SET updated_at = ?, copy_count = copy_count + 1 WHERE id = ?",
                (now, existing["id"]),
            )
        return existing["id"]

    # โ”€โ”€ STAGE 2: SHA-256 Image Content Hash Match โ”€โ”€
    # If content paths differ (e.g. capture_1001.png vs capture_1002.png), compare actual pixel hashes.
    # We filter by file size first (os.path.getsize) to avoid computing SHA-256 on non-matching sizes!
    if item_type == "image" and os.path.isfile(content):
        new_hash = _get_file_hash(content)
        if new_hash:
            new_size = os.path.getsize(content)
            with _db() as conn:
                rows = conn.execute(
                    "SELECT id, content FROM clipboard_items WHERE type = 'image' ORDER BY updated_at DESC LIMIT 100"
                ).fetchall()

            for row in rows:
                existing_path = row["content"]
                if existing_path and os.path.isfile(existing_path):
                    # Fast pre-check: Compare file size before hashing disk bytes
                    if os.path.getsize(existing_path) == new_size:
                        if _get_file_hash(existing_path) == new_hash:
                            # Duplicate image pixels detected! Delete the new redundant temporary file
                            try:
                                os.remove(content)
                            except OSError:
                                pass

                            now = datetime.now().isoformat()
                            with _db() as conn:
                                conn.execute(
                                    "UPDATE clipboard_items SET updated_at = ?, copy_count = copy_count + 1 WHERE id = ?",
                                    (now, row["id"]),
                                )
                            return row["id"]

    # โ”€โ”€ STAGE 3: New Unique Item Insertion โ”€โ”€
    # If neither string content nor SHA-256 hash matched, insert as a brand new item.
    now = datetime.now().isoformat()
    with _db() as conn:
        cursor = conn.execute("""
            INSERT INTO clipboard_items (type, content, preview, is_pinned, copy_count, created_at, updated_at)
            VALUES (?, ?, ?, 0, 1, ?, ?)
        """, (item_type, content, preview, now, now))
        return cursor.lastrowid
Enter fullscreen mode Exit fullscreen mode

3. Zero-Leak Master Password Verification (ui/dashboard.py)

DotGhostBoard allows users to encrypt sensitive clips (e.g., API keys, passwords) using AES-256-GCM.

Master Password Secret Lock Screen & Decryption

When a user copies a secret item from Spotlight or the Dashboard:

  1. _on_copy(item_id) checks if item.get("is_secret") is true.
  2. If the session is locked (_active_key is None), it prompts for the Master Password via LockScreen.
  3. The content is decrypted in-memory only for copying to the OS clipboard.
  4. watcher.mark_self_paste() informs the clipboard watcher to ignore the newly pasted plaintext so it is never saved back to the database as an unencrypted card.

๐Ÿ›ก๏ธ Zero-Leak Security Architecture:

  1. Zero Disk Leak: Decryption happens strictly in-memory during copy (storage.decrypt_item).
  2. Watcher Interception: watcher.mark_self_paste() intercepts the next clipboard polling tick, updating _last_content to ignore the text and preventing the watcher thread from re-saving the unencrypted secret back into SQLite.
  3. UI RAM Purge: When locking the session, on_session_locked() immediately purges plaintext from Qt UI labels (setText("")) so sensitive data never lingers in widget memory.

Note: These protections completely prevent secrets from leaking back into DotGhostBoard's SQLite database or UI widgets. They do not yet control how long the plaintext sits on the OS-level clipboard buffer after pasting โ€” which is why our upcoming v2.0 release introduces an automated 30-second clipboard wipe.

๐Ÿ”— GitHub Source Code: ui/dashboard.py

    def _on_copy(self, item_id: int):
        item = storage.get_item_by_id(item_id)
        if not item:
            return

        if item.get("is_secret"):
            # Check if session is unlocked
            if self._active_key is None:
                dlg = LockScreen(setup=False)
                if dlg.exec() == LockScreen.DialogCode.Accepted:
                    self._active_key = dlg.get_key()
                    self._reset_auto_lock()
                else:
                    self.statusBar().showMessage("โš  Session is locked โ€” unlock to copy secret.")
                    return

            # Decrypt item content in-memory for copying
            plaintext = storage.decrypt_item(item_id, self._active_key)
            if plaintext is None:
                self.statusBar().showMessage("โš  Decryption failed โ€” wrong key or corrupted data.")
                return

            item["content"] = plaintext

        # Mark as self-paste so watcher updates _last_content without re-capturing
        self.watcher.mark_self_paste()
        self.watcher.paste_item_to_clipboard(item)

        # Increment copy count in DB and refresh UI badge
        new_count = storage.increment_copy_count(item_id)
        card = self._cards.get(item_id)
        if card:
            card.update_copy_count(new_count)

        self.statusBar().showMessage(f"Copied! โ˜  (ร—{new_count})")
Enter fullscreen mode Exit fullscreen mode

4. Search Input Debouncing with QTimer

Connecting QLineEdit.textChanged directly to database queries causes UI freezing during rapid typing because every single keystroke executes an SQL query and clears/rebuilds PyQt6 widgets.

We implemented a single-shot QTimer debounce pattern in both the main Dashboard (200ms) and Spotlight (150ms):

๐Ÿ”— GitHub Source Code: ui/dashboard.py | ui/spotlight.py

    # Inside Dashboard.__init__
    self._search_timer = QTimer(self)
    self._search_timer.setSingleShot(True)
    self._search_timer.setInterval(200)
    self._search_timer.timeout.connect(self._do_search)
    self._pending_search_query = ""

    def _on_search(self, query: str):
        """Debounce search box input by 200ms."""
        self._pending_search_query = query
        if not query.strip():
            self._search_timer.stop()
            self._do_search()  # Instant clear on empty input
        else:
            self._search_timer.start()

    def _do_search(self):
        """Execute SQL query & UI rebuild after 200ms pause in typing."""
        query = getattr(self, "_pending_search_query", "")
        # ... SQL search & widget pagination ...
Enter fullscreen mode Exit fullscreen mode

5. Dashboard State Summary Banner (StatsHeaderCard)

At the top of the history feed, StatsHeaderCard provides a live state summary:

  • ๐Ÿ“‹ Today: Total clips captured today.
  • ๐Ÿ”ฅ Top: Most frequently copied clip created today (excluding encrypted secrets).
  • ๐Ÿ“ Pinned: Total count of pinned clips.

Dashboard Stats Header Card

๐Ÿ”— GitHub Source Code: core/storage.py

def get_today_stats() -> dict:
    today_prefix = datetime.now().strftime("%Y-%m-%d")
    with _db() as conn:
        cur1 = conn.execute(
            "SELECT COUNT(*) as cnt FROM clipboard_items WHERE created_at LIKE ?",
            (f"{today_prefix}%",)
        )
        total_today = cur1.fetchone()["cnt"]

        cur3 = conn.execute(
            """
            SELECT preview, content, copy_count
            FROM clipboard_items
            WHERE copy_count > 0
              AND created_at LIKE ?
              AND COALESCE(is_secret, 0) = 0
            ORDER BY copy_count DESC, updated_at DESC
            LIMIT 1
            """,
            (f"{today_prefix}%",)
        )
        top_row = cur3.fetchone()
        # ... format & return dict ...
Enter fullscreen mode Exit fullscreen mode

๐Ÿงช Testing & Verification

We maintained strict TDD practices throughout development, writing 184 unit tests using pytest:

๐Ÿ”— GitHub Test Suite: tests/test_v155_polish.py

============================= 184 passed in 8.40s ==============================
Enter fullscreen mode Exit fullscreen mode

Every component โ€” from relative timestamp calculation (_format_time) to SHA-256 image deduplication and reset_copy_count boolean return values โ€” is covered by automated unit tests in tests/test_v155_polish.py.


๐Ÿ“ฆ Packaging & Installation on Linux

DotGhostBoard is packaged for multiple Linux distributions including AppImage, .deb, Arch Linux (.pkg.tar.zst), and generic .tar.gz:

You can download pre-built release assets from:

# Build DEB package locally
./scripts/build_deb.sh

# Install on Kali Linux / Debian / Ubuntu
sudo dpkg -i dotghostboard_1.5.5_amd64.deb
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”ฎ What's Next? The Roadmap to DotGhostBoard v2.x (Cerberus)

With the v1.5.x milestone complete, we are officially transitioning development to DotGhostBoard v2.x!

Here is a sneak peek at the exciting features engineered for the upcoming v2.0.0 (Cerberus) release:

  • ๐Ÿ”’ The Vault (vault.db): An isolated, zero-knowledge encrypted database completely separate from ghost.db, protected by an independent Master Password for ultimate credential security.
  • ๐Ÿง  Regex Smart Secret Detection: Pattern-based heuristic detection for API Keys, AWS credentials (AKIA...), GitHub PATs (ghp_...), JWT tokens, and high-entropy secrets โ€” automatically prompting users to move them into The Vault.
  • โณ 30-Second Auto-Wipe: Automatic clipboard buffer wiping 30 seconds after pasting sensitive tokens from the Vault.
  • ๐Ÿ•ต๏ธ Paranoia / Zero-Logging Mode: A single-click stealth mode toggle that temporarily halts all local database logging.

๐ŸŒŸ Conclusion & Performance Benchmarks

Building DotGhostBoard v1.5.5 proved that Linux desktop utilities don't need heavy web runtimes to deliver modern, fluid UI experiences. By choosing Python 3, PyQt6, and SQLite, we measured significant performance advantages in our local testing compared to typical Electron alternatives:

  • ๐ŸŸข Full Runtime RAM: ~85 MB process RSS (GUI + SQLite DB + Spotlight dialog loaded) vs ~350 MB+ in typical Electron clipboard tools.
  • โšก Spotlight Query Latency: ~3.5 ms average execution time across 10 runs (min 2.23 ms, max 4.90 ms).
  • ๐Ÿ’พ Disk Optimization: SHA-256 deduplication saved ~85% disk space during screenshot-heavy debugging sessions.
  • ๐Ÿ“Š Automated Suite: 184 / 184 tests passing in 8.32s.

Check out the full source code, download release packages, and star the repository on GitHub:
๐Ÿ‘‰ GitHub Repository: https://github.com/kareem2099/DotGhostBoard
๐Ÿ‘‰ GitHub Release (v1.5.5): https://github.com/kareem2099/DotGhostBoard/releases/tag/v1.5.5
๐Ÿ‘‰ OpenDesktop / Pling Store: https://www.opendesktop.org/p/2353623/


๐Ÿ’ฌ Technical Discussion

If you were designing our v2.0 Regex Smart Secret Detection Engine, what would be the very first pattern or entropy rule you'd include to catch leaked keys? Drop your regex patterns or PR suggestions below! ๐Ÿš€

Top comments (0)