# ❌ 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