Tools: Essential Guide: I Built a Clipboard Manager for Kali Linux — And Learned Way More Than Expected

Tools: Essential Guide: I Built a Clipboard Manager for Kali Linux — And Learned Way More Than Expected

What Is It?

The Architecture Decision That Changed Everything

The Clipboard Watcher

The Storage Layer

Lazy Image Loading (v1.2.0)

Video Thumbnails via ffmpeg (v1.2.0)

Drag & Drop for Pinned Cards (v1.2.0)

The Test Suite

The Stylesheet

What I Actually Learned

What's Next (v1.3.0 "Wraith")

Try It 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 👻 A clipboard manager for Linux (built for Kali, works everywhere with PyQt6 and a dark neon soul). The original plan had a pynput-based global hotkey listener: The fix: use QLocalServer — PyQt6's IPC mechanism. When you press Ctrl+Alt+V (registered as a system shortcut), the OS runs: …which launches a second instance, sends b"SHOW" to the IPC socket, and exits immediately. No keylogger. No root. No Wayland issues. The watcher is a QObject with a QTimer polling every 500ms. The key insight was using a content signature instead of calling .tobytes(): 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. SQLite with a context manager to guarantee connection cleanup: The schema — notice the sort_order column added in v1.2.0 for drag-and-drop reordering, and the preview column for thumbnails: Adding a new column to an existing database without breaking it: 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. Before: loading 50 image cards = 2-3 second freeze.After: instant paint, thumbnails pop in one by one like a modern feed. When a video file path is copied, we extract the first frame using a background QThread — never blocking the UI: The QThread that wraps it: Three things I love about this design: Pinned cards get a ⠿ drag handle and use QDrag with a custom MIME type carrying the item_id: On drop in dashboard.py, we rebuild sort_order for all pinned cards in their new visual order and persist it: The trick to testing storage without touching the real database: Testing ffmpeg graceful failure without actually needing ffmpeg: The whole UI runs on a single ghost.qss file. QSS supports property selectors, which makes state-based styling elegant: Triggering a state change from Python: 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# ❌ 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 -weight: 500;">start(self): self._listener.-weight: 500;">start() # This is basically a keylogger # ❌ 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 -weight: 500;">start(self): self._listener.-weight: 500;">start() # This is basically a keylogger # ❌ 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 -weight: 500;">start(self): self._listener.-weight: 500;">start() # This is basically a keylogger # ✅ 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 # ✅ 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 # ✅ 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 python3 main.py --show python3 main.py --show python3 main.py --show # 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) # 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) # 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) # 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 # 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 # 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 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 ) 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 ) 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 ) 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 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 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 # 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) # 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) # 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) # 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 # 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 # 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 # 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) # 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) # 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) # 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) # 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) # 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) # 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.-weight: 500;">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) # 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.-weight: 500;">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) # 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.-weight: 500;">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) # 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") # 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") # 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") # 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_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_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 ✅ 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 ✅ 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 ✅ /* 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; } /* 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; } /* 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; } 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) 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) 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) -weight: 500;">git clone https://github.com/kareem2099/DotGhostBoard cd DotGhostBoard -weight: 500;">pip -weight: 500;">install -r requirements.txt python3 scripts/generate_icon.py bash scripts/-weight: 500;">install.sh python3 main.py -weight: 500;">git clone https://github.com/kareem2099/DotGhostBoard cd DotGhostBoard -weight: 500;">pip -weight: 500;">install -r requirements.txt python3 scripts/generate_icon.py bash scripts/-weight: 500;">install.sh python3 main.py -weight: 500;">git clone https://github.com/kareem2099/DotGhostBoard cd DotGhostBoard -weight: 500;">pip -weight: 500;">install -r requirements.txt python3 scripts/generate_icon.py bash scripts/-weight: 500;">install.sh python3 main.py - 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 - pynput runs 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 - 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 - Tags and collections - Multi-select + bulk delete - Export to JSON/Markdown - AES-256 encryption coming in v1.4.0