Tools: I Built a Clipboard Manager for Linux with AES-256 Encryption — DotGhostBoard v1.4.0 Eclipse (2026)
Watch the 1-minute overview:
👻 DotGhostBoard v1.4.0 — Eclipse
Background — Why Another Clipboard Manager?
What's in Eclipse
The Crypto Engine
Password Verification Without Storing the Password
The Lock Screen
Per-Item Secret Toggle
Auto-Lock Inactivity Timer
Stealth Mode
Secure Delete
App Filter — Whitelist / Blacklist
SQLite Schema Migration
The Settings UI
About Tab
Test Coverage
Install & Run
Option 1 — Download a Release (Recommended)
Option 2 — OpenDesktop.org
What's Next — v1.5.0 Nexus A deep dive into building Eclipse — the security layer of DotGhostBoard: AES-256-GCM encryption, master password lock screen, stealth mode, secure delete, and app filtering. Full code walkthrough. TL;DR — I built a clipboard manager for Linux (PyQt6 + SQLite) and just shipped its security layer: AES-256-GCM encryption, a master password lock screen, per-item secret toggle, stealth mode, secure file deletion, and app-level capture filtering. Zero telemetry. Zero Electron. 100% Python. Every clipboard manager I tried on Kali was either too heavy (Electron), too basic, or required a network connection. I wanted something that: So I built DotGhostBoard as part of my DotSuite toolkit. It's been through four versions: The hardest design decision was key management. I didn't want to store the password anywhere — only a verifier that proves the password is correct. Wire format: [ 12-byte nonce ][ ciphertext ][ 16-byte GCM auth tag ] — all base64url-encoded. The GCM tag means any tampering is detected on decryption, not silently ignored. The raw password is never stored — not even hashed. Only an encrypted sentinel string lives on disk. Delete eclipse.salt and the ciphertext becomes permanently unrecoverable. I wanted it to feel like a real lock screen — not just a dialog you can dismiss. Three layers of protection: This was the UX challenge. I didn't want to encrypt everything automatically — that would be aggressive and confusing. Instead, the user right-clicks any text card: When encrypted, the card shows an amber overlay — content is completely hidden: Clicking 👁 Reveal fires a signal up to Dashboard, which decrypts using the session key and pushes the plaintext back down: When the session locks, all revealed cards re-hide automatically: Simple and reliable — a single-shot QTimer that resets on every mouse/keyboard event. Hide from taskbar and Alt+Tab without breaking the UI: This sets X11 _NET_WM_STATE hints directly via xprop — works on all EWMH-compliant window managers (GNOME, KDE, XFCE, i3 with gaps, etc.). A plain os.remove() doesn't zero out disk sectors. Eclipse overwrites first: ⚠️ Note on SSDs: Wear-levelling on SSDs means software overwrite can't guarantee forensic destruction. For maximum security, combine this with full-disk encryption (LUKS). Don't capture from KeePassXC? Easy: Process detection uses xdotool + /proc/<pid>/comm: Eclipse adds one column to the existing DB — backward compatible: And the encrypt/decrypt helpers work at the storage layer: Eclipse gets its own tab in the Settings dialog: The About tab shows version info, system info (live Python/Qt versions), MIT license, and all social/community links. Eclipse ships with 28 unit tests: DotGhostBoard ships pre-built binaries via GitHub Actions CI — every push to main automatically builds for 4 platforms: Download from the Releases page or grab the latest build artifacts directly from GitHub Actions. Linux AppImage (quickest): DotGhostBoard is also published on OpenDesktop.org — the Linux app store used by KDE, GNOME, and OCS-compatible installers. You can install it directly from the OpenDesktop page using the OCS-Install button if your distro supports it. Requirements: Python 3.11+, PyQt6, Pillow, cryptography, xdotool Built with PyQt6, SQLite, and the cryptography library. Part of the DotSuite toolkit by FreeRave. Star the repo if you find it useful ⭐ 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
v1.4.0 Eclipse
├── 🔐 AES-256-GCM encryption engine
├── 🔑 Master password (PBKDF2-SHA256, 600K iterations)
├── 🔒 Lock screen — frameless, Escape-proof
├── 👁 Per-item secret toggle (right-click → Mark as Secret)
├── ⏱ Auto-lock after N minutes of inactivity
├── 👁 Stealth mode — hide from taskbar + Alt+Tab
├── 🗑 Secure delete — multi-pass byte overwrite
└── 🛡 App filter — whitelist / blacklist by process name
v1.4.0 Eclipse
├── 🔐 AES-256-GCM encryption engine
├── 🔑 Master password (PBKDF2-SHA256, 600K iterations)
├── 🔒 Lock screen — frameless, Escape-proof
├── 👁 Per-item secret toggle (right-click → Mark as Secret)
├── ⏱ Auto-lock after N minutes of inactivity
├── 👁 Stealth mode — hide from taskbar + Alt+Tab
├── 🗑 Secure delete — multi-pass byte overwrite
└── 🛡 App filter — whitelist / blacklist by process name
v1.4.0 Eclipse
├── 🔐 AES-256-GCM encryption engine
├── 🔑 Master password (PBKDF2-SHA256, 600K iterations)
├── 🔒 Lock screen — frameless, Escape-proof
├── 👁 Per-item secret toggle (right-click → Mark as Secret)
├── ⏱ Auto-lock after N minutes of inactivity
├── 👁 Stealth mode — hide from taskbar + Alt+Tab
├── 🗑 Secure delete — multi-pass byte overwrite
└── 🛡 App filter — whitelist / blacklist by process name
# core/crypto.py from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import os, base64 _NONCE_SIZE = 12 # GCM standard (96-bit)
_KDF_ITER = 600_000 def derive_key(password: str) -> bytes: """PBKDF2-HMAC-SHA256 — same password + same salt = same key.""" salt = _load_or_create_salt() # 256-bit, stored at ~/.config/dotghostboard/eclipse.salt kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=salt, iterations=_KDF_ITER, ) return kdf.derive(password.encode("utf-8")) def encrypt(plaintext: str, key: bytes) -> str: """AES-256-GCM → base64url token safe for SQLite TEXT columns.""" nonce = os.urandom(_NONCE_SIZE) ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None) return base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii") def decrypt(token: str, key: bytes) -> str: """Raises ValueError on wrong key or tampered data.""" raw = base64.urlsafe_b64decode(token.encode("ascii")) nonce = raw[:_NONCE_SIZE] ct = raw[_NONCE_SIZE:] return AESGCM(key).decrypt(nonce, ct, None).decode("utf-8")
# core/crypto.py from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import os, base64 _NONCE_SIZE = 12 # GCM standard (96-bit)
_KDF_ITER = 600_000 def derive_key(password: str) -> bytes: """PBKDF2-HMAC-SHA256 — same password + same salt = same key.""" salt = _load_or_create_salt() # 256-bit, stored at ~/.config/dotghostboard/eclipse.salt kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=salt, iterations=_KDF_ITER, ) return kdf.derive(password.encode("utf-8")) def encrypt(plaintext: str, key: bytes) -> str: """AES-256-GCM → base64url token safe for SQLite TEXT columns.""" nonce = os.urandom(_NONCE_SIZE) ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None) return base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii") def decrypt(token: str, key: bytes) -> str: """Raises ValueError on wrong key or tampered data.""" raw = base64.urlsafe_b64decode(token.encode("ascii")) nonce = raw[:_NONCE_SIZE] ct = raw[_NONCE_SIZE:] return AESGCM(key).decrypt(nonce, ct, None).decode("utf-8")
# core/crypto.py from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import os, base64 _NONCE_SIZE = 12 # GCM standard (96-bit)
_KDF_ITER = 600_000 def derive_key(password: str) -> bytes: """PBKDF2-HMAC-SHA256 — same password + same salt = same key.""" salt = _load_or_create_salt() # 256-bit, stored at ~/.config/dotghostboard/eclipse.salt kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=salt, iterations=_KDF_ITER, ) return kdf.derive(password.encode("utf-8")) def encrypt(plaintext: str, key: bytes) -> str: """AES-256-GCM → base64url token safe for SQLite TEXT columns.""" nonce = os.urandom(_NONCE_SIZE) ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None) return base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii") def decrypt(token: str, key: bytes) -> str: """Raises ValueError on wrong key or tampered data.""" raw = base64.urlsafe_b64decode(token.encode("ascii")) nonce = raw[:_NONCE_SIZE] ct = raw[_NONCE_SIZE:] return AESGCM(key).decrypt(nonce, ct, None).decode("utf-8")
_VERIFY_TOKEN = "DOTGHOST_ECLIPSE_OK" def save_master_password(password: str) -> None: key = derive_key(password) token = encrypt(_VERIFY_TOKEN, key) # store encrypted sentinel with open(_VERIFY_FILE, "w") as f: f.write(token) def verify_password(password: str) -> bool: with open(_VERIFY_FILE) as f: stored = f.read().strip() try: return decrypt(stored, derive_key(password)) == _VERIFY_TOKEN except Exception: return False
_VERIFY_TOKEN = "DOTGHOST_ECLIPSE_OK" def save_master_password(password: str) -> None: key = derive_key(password) token = encrypt(_VERIFY_TOKEN, key) # store encrypted sentinel with open(_VERIFY_FILE, "w") as f: f.write(token) def verify_password(password: str) -> bool: with open(_VERIFY_FILE) as f: stored = f.read().strip() try: return decrypt(stored, derive_key(password)) == _VERIFY_TOKEN except Exception: return False
_VERIFY_TOKEN = "DOTGHOST_ECLIPSE_OK" def save_master_password(password: str) -> None: key = derive_key(password) token = encrypt(_VERIFY_TOKEN, key) # store encrypted sentinel with open(_VERIFY_FILE, "w") as f: f.write(token) def verify_password(password: str) -> bool: with open(_VERIFY_FILE) as f: stored = f.read().strip() try: return decrypt(stored, derive_key(password)) == _VERIFY_TOKEN except Exception: return False
# ui/lock_screen.py (key parts) class LockScreen(QDialog): MAX_ATTEMPTS = 5 def __init__(self, parent=None, *, setup: bool = False): super().__init__(parent) self.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.FramelessWindowHint # no title bar ) self.setModal(True) def keyPressEvent(self, event): # Escape cannot close a lock screen if event.key() != Qt.Key.Key_Escape: super().keyPressEvent(event) def closeEvent(self, event): # Window manager X button is also blocked if self._key is None: event.ignore() else: super().closeEvent(event)
# ui/lock_screen.py (key parts) class LockScreen(QDialog): MAX_ATTEMPTS = 5 def __init__(self, parent=None, *, setup: bool = False): super().__init__(parent) self.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.FramelessWindowHint # no title bar ) self.setModal(True) def keyPressEvent(self, event): # Escape cannot close a lock screen if event.key() != Qt.Key.Key_Escape: super().keyPressEvent(event) def closeEvent(self, event): # Window manager X button is also blocked if self._key is None: event.ignore() else: super().closeEvent(event)
# ui/lock_screen.py (key parts) class LockScreen(QDialog): MAX_ATTEMPTS = 5 def __init__(self, parent=None, *, setup: bool = False): super().__init__(parent) self.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.FramelessWindowHint # no title bar ) self.setModal(True) def keyPressEvent(self, event): # Escape cannot close a lock screen if event.key() != Qt.Key.Key_Escape: super().keyPressEvent(event) def closeEvent(self, event): # Window manager X button is also blocked if self._key is None: event.ignore() else: super().closeEvent(event)
# ui/dashboard.py — context menu def _on_card_context_menu(self, pos, card: ItemCard): menu = QMenu(self) # Only show encryption options if master password is set # and item is a text card if has_master_password() and card.item_type == "text": menu.addSeparator() if card.is_secret: action = QAction("🔓 Remove Encryption", self) action.triggered.connect(lambda: self._decrypt_card(card.item_id)) else: action = QAction("🔐 Mark as Secret", self) action.triggered.connect(lambda: self._encrypt_card(card.item_id)) menu.addAction(action) menu.exec(card.mapToGlobal(pos))
# ui/dashboard.py — context menu def _on_card_context_menu(self, pos, card: ItemCard): menu = QMenu(self) # Only show encryption options if master password is set # and item is a text card if has_master_password() and card.item_type == "text": menu.addSeparator() if card.is_secret: action = QAction("🔓 Remove Encryption", self) action.triggered.connect(lambda: self._decrypt_card(card.item_id)) else: action = QAction("🔐 Mark as Secret", self) action.triggered.connect(lambda: self._encrypt_card(card.item_id)) menu.addAction(action) menu.exec(card.mapToGlobal(pos))
# ui/dashboard.py — context menu def _on_card_context_menu(self, pos, card: ItemCard): menu = QMenu(self) # Only show encryption options if master password is set # and item is a text card if has_master_password() and card.item_type == "text": menu.addSeparator() if card.is_secret: action = QAction("🔓 Remove Encryption", self) action.triggered.connect(lambda: self._decrypt_card(card.item_id)) else: action = QAction("🔐 Mark as Secret", self) action.triggered.connect(lambda: self._encrypt_card(card.item_id)) menu.addAction(action) menu.exec(card.mapToGlobal(pos))
# ui/widgets.py — the encrypted card face def _build_secret_overlay(self) -> QWidget: overlay = QFrame() overlay.setObjectName("SecretOverlay") overlay.setStyleSheet(""" QFrame#SecretOverlay { background: rgba(255, 153, 0, 0.05); border: 1px dashed #ff990055; border-radius: 6px; } """) overlay.setFixedHeight(56) # ... lock icon + hint label return overlay
# ui/widgets.py — the encrypted card face def _build_secret_overlay(self) -> QWidget: overlay = QFrame() overlay.setObjectName("SecretOverlay") overlay.setStyleSheet(""" QFrame#SecretOverlay { background: rgba(255, 153, 0, 0.05); border: 1px dashed #ff990055; border-radius: 6px; } """) overlay.setFixedHeight(56) # ... lock icon + hint label return overlay
# ui/widgets.py — the encrypted card face def _build_secret_overlay(self) -> QWidget: overlay = QFrame() overlay.setObjectName("SecretOverlay") overlay.setStyleSheet(""" QFrame#SecretOverlay { background: rgba(255, 153, 0, 0.05); border: 1px dashed #ff990055; border-radius: 6px; } """) overlay.setFixedHeight(56) # ... lock icon + hint label return overlay
# Dashboard receives the reveal request:
def _on_reveal_requested(self, item_id: int) -> None: if self._active_key is None: self.statusBar().showMessage("⚠ Session is locked — unlock first.") return plaintext = storage.decrypt_item(item_id, self._active_key) if plaintext: self._cards[item_id].reveal_content(plaintext)
# Dashboard receives the reveal request:
def _on_reveal_requested(self, item_id: int) -> None: if self._active_key is None: self.statusBar().showMessage("⚠ Session is locked — unlock first.") return plaintext = storage.decrypt_item(item_id, self._active_key) if plaintext: self._cards[item_id].reveal_content(plaintext)
# Dashboard receives the reveal request:
def _on_reveal_requested(self, item_id: int) -> None: if self._active_key is None: self.statusBar().showMessage("⚠ Session is locked — unlock first.") return plaintext = storage.decrypt_item(item_id, self._active_key) if plaintext: self._cards[item_id].reveal_content(plaintext)
# ItemCard shows the plaintext:
def reveal_content(self, plaintext: str): self._revealed_label.setText(plaintext[:120] + "…" if len(plaintext) > 120 else plaintext) self._overlay_widget.hide() self._revealed_label.show() self._is_revealed = True self._secret_btn.setText("🔒 Lock")
# ItemCard shows the plaintext:
def reveal_content(self, plaintext: str): self._revealed_label.setText(plaintext[:120] + "…" if len(plaintext) > 120 else plaintext) self._overlay_widget.hide() self._revealed_label.show() self._is_revealed = True self._secret_btn.setText("🔒 Lock")
# ItemCard shows the plaintext:
def reveal_content(self, plaintext: str): self._revealed_label.setText(plaintext[:120] + "…" if len(plaintext) > 120 else plaintext) self._overlay_widget.hide() self._revealed_label.show() self._is_revealed = True self._secret_btn.setText("🔒 Lock")
# Dashboard._lock():
for card in self._cards.values(): card.on_session_locked() # re-hides revealed content # ItemCard.on_session_locked():
def on_session_locked(self): if self.is_secret and self._is_revealed: self._revealed_label.hide() self._revealed_label.setText("") # clear plaintext from memory self._overlay_widget.show()
# Dashboard._lock():
for card in self._cards.values(): card.on_session_locked() # re-hides revealed content # ItemCard.on_session_locked():
def on_session_locked(self): if self.is_secret and self._is_revealed: self._revealed_label.hide() self._revealed_label.setText("") # clear plaintext from memory self._overlay_widget.show()
# Dashboard._lock():
for card in self._cards.values(): card.on_session_locked() # re-hides revealed content # ItemCard.on_session_locked():
def on_session_locked(self): if self.is_secret and self._is_revealed: self._revealed_label.hide() self._revealed_label.setText("") # clear plaintext from memory self._overlay_widget.show()
# Dashboard.__init__:
self._auto_lock_timer = QTimer(self)
self._auto_lock_timer.setSingleShot(True)
self._auto_lock_timer.timeout.connect(self._lock) # Reset on any user interaction:
def mousePressEvent(self, event): self._reset_auto_lock() super().mousePressEvent(event) def _reset_auto_lock(self): minutes = self._settings.get("auto_lock_minutes", 0) if minutes > 0 and has_master_password(): self._auto_lock_timer.start(minutes * 60 * 1000) else: self._auto_lock_timer.stop()
# Dashboard.__init__:
self._auto_lock_timer = QTimer(self)
self._auto_lock_timer.setSingleShot(True)
self._auto_lock_timer.timeout.connect(self._lock) # Reset on any user interaction:
def mousePressEvent(self, event): self._reset_auto_lock() super().mousePressEvent(event) def _reset_auto_lock(self): minutes = self._settings.get("auto_lock_minutes", 0) if minutes > 0 and has_master_password(): self._auto_lock_timer.start(minutes * 60 * 1000) else: self._auto_lock_timer.stop()
# Dashboard.__init__:
self._auto_lock_timer = QTimer(self)
self._auto_lock_timer.setSingleShot(True)
self._auto_lock_timer.timeout.connect(self._lock) # Reset on any user interaction:
def mousePressEvent(self, event): self._reset_auto_lock() super().mousePressEvent(event) def _reset_auto_lock(self): minutes = self._settings.get("auto_lock_minutes", 0) if minutes > 0 and has_master_password(): self._auto_lock_timer.start(minutes * 60 * 1000) else: self._auto_lock_timer.stop()
def _set_stealth(self, enable: bool) -> None: import subprocess try: wid = hex(int(self.winId())) if enable: subprocess.run([ "xprop", "-id", wid, "-f", "_NET_WM_STATE", "32a", "-set", "_NET_WM_STATE", "_NET_WM_STATE_SKIP_TASKBAR,_NET_WM_STATE_SKIP_PAGER", ], check=False, timeout=2, capture_output=True) else: subprocess.run( ["xprop", "-id", wid, "-remove", "_NET_WM_STATE"], check=False, timeout=2, capture_output=True ) except Exception: pass
def _set_stealth(self, enable: bool) -> None: import subprocess try: wid = hex(int(self.winId())) if enable: subprocess.run([ "xprop", "-id", wid, "-f", "_NET_WM_STATE", "32a", "-set", "_NET_WM_STATE", "_NET_WM_STATE_SKIP_TASKBAR,_NET_WM_STATE_SKIP_PAGER", ], check=False, timeout=2, capture_output=True) else: subprocess.run( ["xprop", "-id", wid, "-remove", "_NET_WM_STATE"], check=False, timeout=2, capture_output=True ) except Exception: pass
def _set_stealth(self, enable: bool) -> None: import subprocess try: wid = hex(int(self.winId())) if enable: subprocess.run([ "xprop", "-id", wid, "-f", "_NET_WM_STATE", "32a", "-set", "_NET_WM_STATE", "_NET_WM_STATE_SKIP_TASKBAR,_NET_WM_STATE_SKIP_PAGER", ], check=False, timeout=2, capture_output=True) else: subprocess.run( ["xprop", "-id", wid, "-remove", "_NET_WM_STATE"], check=False, timeout=2, capture_output=True ) except Exception: pass
# core/secure_delete.py def secure_delete(path: str, passes: int = 3) -> bool: if not os.path.isfile(path): return False size = os.path.getsize(path) if size > 0: with open(path, "r+b") as fh: for pass_num in range(passes): fh.seek(0) # Alternate: random → zeros → random if pass_num % 2 == 1: fh.write(b"\x00" * size) else: fh.write(os.urandom(size)) fh.flush() os.fsync(fh.fileno()) # force kernel flush os.remove(path) return True
# core/secure_delete.py def secure_delete(path: str, passes: int = 3) -> bool: if not os.path.isfile(path): return False size = os.path.getsize(path) if size > 0: with open(path, "r+b") as fh: for pass_num in range(passes): fh.seek(0) # Alternate: random → zeros → random if pass_num % 2 == 1: fh.write(b"\x00" * size) else: fh.write(os.urandom(size)) fh.flush() os.fsync(fh.fileno()) # force kernel flush os.remove(path) return True
# core/secure_delete.py def secure_delete(path: str, passes: int = 3) -> bool: if not os.path.isfile(path): return False size = os.path.getsize(path) if size > 0: with open(path, "r+b") as fh: for pass_num in range(passes): fh.seek(0) # Alternate: random → zeros → random if pass_num % 2 == 1: fh.write(b"\x00" * size) else: fh.write(os.urandom(size)) fh.flush() os.fsync(fh.fileno()) # force kernel flush os.remove(path) return True
# core/app_filter.py class AppFilter: def should_capture(self) -> bool: if not self.app_list: return True # no filter = capture everything identifiers = get_active_app_identifiers() if not identifiers: return True # fail-open if xdotool unavailable matched = self._matches(identifiers) return not matched if self.mode == "blacklist" else matched def _matches(self, identifiers: set[str]) -> bool: # Substring match: "keepass" matches "org.keepassxc.keepassxc" return any( app in ident for app in self.app_list for ident in identifiers )
# core/app_filter.py class AppFilter: def should_capture(self) -> bool: if not self.app_list: return True # no filter = capture everything identifiers = get_active_app_identifiers() if not identifiers: return True # fail-open if xdotool unavailable matched = self._matches(identifiers) return not matched if self.mode == "blacklist" else matched def _matches(self, identifiers: set[str]) -> bool: # Substring match: "keepass" matches "org.keepassxc.keepassxc" return any( app in ident for app in self.app_list for ident in identifiers )
# core/app_filter.py class AppFilter: def should_capture(self) -> bool: if not self.app_list: return True # no filter = capture everything identifiers = get_active_app_identifiers() if not identifiers: return True # fail-open if xdotool unavailable matched = self._matches(identifiers) return not matched if self.mode == "blacklist" else matched def _matches(self, identifiers: set[str]) -> bool: # Substring match: "keepass" matches "org.keepassxc.keepassxc" return any( app in ident for app in self.app_list for ident in identifiers )
def get_active_app_identifiers() -> set[str]: win_id = _run(["xdotool", "getactivewindow"]) if not win_id: return set() pid = _run(["xdotool", "getwindowpid", win_id]) identifiers = set() # Read process name from /proc try: with open(f"/proc/{pid}/comm") as f: identifiers.add(f.read().strip().lower()) except OSError: pass # Also check WM_CLASS for apps with wrappers wm_class = _run(["xprop", "-id", win_id, "WM_CLASS"]) if wm_class: match = re.search(r'"([^"]+)"', wm_class) if match: identifiers.add(match.group(1).lower()) return identifiers
def get_active_app_identifiers() -> set[str]: win_id = _run(["xdotool", "getactivewindow"]) if not win_id: return set() pid = _run(["xdotool", "getwindowpid", win_id]) identifiers = set() # Read process name from /proc try: with open(f"/proc/{pid}/comm") as f: identifiers.add(f.read().strip().lower()) except OSError: pass # Also check WM_CLASS for apps with wrappers wm_class = _run(["xprop", "-id", win_id, "WM_CLASS"]) if wm_class: match = re.search(r'"([^"]+)"', wm_class) if match: identifiers.add(match.group(1).lower()) return identifiers
def get_active_app_identifiers() -> set[str]: win_id = _run(["xdotool", "getactivewindow"]) if not win_id: return set() pid = _run(["xdotool", "getwindowpid", win_id]) identifiers = set() # Read process name from /proc try: with open(f"/proc/{pid}/comm") as f: identifiers.add(f.read().strip().lower()) except OSError: pass # Also check WM_CLASS for apps with wrappers wm_class = _run(["xprop", "-id", win_id, "WM_CLASS"]) if wm_class: match = re.search(r'"([^"]+)"', wm_class) if match: identifiers.add(match.group(1).lower()) return identifiers
# core/storage.py — init_db() # Migration: add is_secret for Eclipse v1.4.0
try: conn.execute( "ALTER TABLE clipboard_items ADD COLUMN is_secret INTEGER DEFAULT 0" )
except Exception: pass # column already exists — safe to ignore
# core/storage.py — init_db() # Migration: add is_secret for Eclipse v1.4.0
try: conn.execute( "ALTER TABLE clipboard_items ADD COLUMN is_secret INTEGER DEFAULT 0" )
except Exception: pass # column already exists — safe to ignore
# core/storage.py — init_db() # Migration: add is_secret for Eclipse v1.4.0
try: conn.execute( "ALTER TABLE clipboard_items ADD COLUMN is_secret INTEGER DEFAULT 0" )
except Exception: pass # column already exists — safe to ignore
def encrypt_item(item_id: int, key: bytes) -> bool: """Encrypt content in-place. Returns False if already encrypted.""" from core.crypto import encrypt as _encrypt item = get_item_by_id(item_id) if not item or item.get("is_secret") or item["type"] != "text": return False ciphertext = _encrypt(item["content"], key) with _db() as conn: conn.execute( "UPDATE clipboard_items SET content = ?, is_secret = 1 WHERE id = ?", (ciphertext, item_id) ) return True def decrypt_item(item_id: int, key: bytes) -> str | None: """Decrypt and return plaintext — does NOT modify DB.""" from core.crypto import decrypt as _decrypt item = get_item_by_id(item_id) if not item: return None if not item.get("is_secret"): return item["content"] # not encrypted, pass through try: return _decrypt(item["content"], key) except ValueError: return None # wrong key or corrupted
def encrypt_item(item_id: int, key: bytes) -> bool: """Encrypt content in-place. Returns False if already encrypted.""" from core.crypto import encrypt as _encrypt item = get_item_by_id(item_id) if not item or item.get("is_secret") or item["type"] != "text": return False ciphertext = _encrypt(item["content"], key) with _db() as conn: conn.execute( "UPDATE clipboard_items SET content = ?, is_secret = 1 WHERE id = ?", (ciphertext, item_id) ) return True def decrypt_item(item_id: int, key: bytes) -> str | None: """Decrypt and return plaintext — does NOT modify DB.""" from core.crypto import decrypt as _decrypt item = get_item_by_id(item_id) if not item: return None if not item.get("is_secret"): return item["content"] # not encrypted, pass through try: return _decrypt(item["content"], key) except ValueError: return None # wrong key or corrupted
def encrypt_item(item_id: int, key: bytes) -> bool: """Encrypt content in-place. Returns False if already encrypted.""" from core.crypto import encrypt as _encrypt item = get_item_by_id(item_id) if not item or item.get("is_secret") or item["type"] != "text": return False ciphertext = _encrypt(item["content"], key) with _db() as conn: conn.execute( "UPDATE clipboard_items SET content = ?, is_secret = 1 WHERE id = ?", (ciphertext, item_id) ) return True def decrypt_item(item_id: int, key: bytes) -> str | None: """Decrypt and return plaintext — does NOT modify DB.""" from core.crypto import decrypt as _decrypt item = get_item_by_id(item_id) if not item: return None if not item.get("is_secret"): return item["content"] # not encrypted, pass through try: return _decrypt(item["content"], key) except ValueError: return None # wrong key or corrupted
pytest tests/test_eclipse.py -v
pytest tests/test_eclipse.py -v
pytest tests/test_eclipse.py -v
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_decrypt_roundtrip
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_produces_different_tokens_each_call
PASSED tests/test_eclipse.py::TestCrypto::test_wrong_key_raises_value_error
PASSED tests/test_eclipse.py::TestCrypto::test_tampered_ciphertext_raises
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_unicode_characters
PASSED tests/test_eclipse.py::TestCrypto::test_master_password_flow
PASSED tests/test_eclipse.py::TestCrypto::test_same_password_derives_same_key
PASSED tests/test_eclipse.py::TestSecureDelete::test_file_is_gone_after_secure_delete
PASSED tests/test_eclipse.py::TestSecureDelete::test_original_content_overwritten
PASSED tests/test_eclipse.py::TestAppFilter::test_blacklist_blocks_matched_app
PASSED tests/test_eclipse.py::TestAppFilter::test_whitelist_allows_matched_app
PASSED tests/test_eclipse.py::TestAppFilter::test_detection_failure_fails_open
PASSED tests/test_eclipse.py::TestStorageEclipse::test_encrypt_item_stores_ciphertext
PASSED tests/test_eclipse.py::TestStorageEclipse::test_decrypt_item_returns_plaintext
PASSED tests/test_eclipse.py::TestStorageEclipse::test_decrypt_wrong_key_returns_none
... 13 more ...
28 passed in 1.84s
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_decrypt_roundtrip
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_produces_different_tokens_each_call
PASSED tests/test_eclipse.py::TestCrypto::test_wrong_key_raises_value_error
PASSED tests/test_eclipse.py::TestCrypto::test_tampered_ciphertext_raises
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_unicode_characters
PASSED tests/test_eclipse.py::TestCrypto::test_master_password_flow
PASSED tests/test_eclipse.py::TestCrypto::test_same_password_derives_same_key
PASSED tests/test_eclipse.py::TestSecureDelete::test_file_is_gone_after_secure_delete
PASSED tests/test_eclipse.py::TestSecureDelete::test_original_content_overwritten
PASSED tests/test_eclipse.py::TestAppFilter::test_blacklist_blocks_matched_app
PASSED tests/test_eclipse.py::TestAppFilter::test_whitelist_allows_matched_app
PASSED tests/test_eclipse.py::TestAppFilter::test_detection_failure_fails_open
PASSED tests/test_eclipse.py::TestStorageEclipse::test_encrypt_item_stores_ciphertext
PASSED tests/test_eclipse.py::TestStorageEclipse::test_decrypt_item_returns_plaintext
PASSED tests/test_eclipse.py::TestStorageEclipse::test_decrypt_wrong_key_returns_none
... 13 more ...
28 passed in 1.84s
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_decrypt_roundtrip
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_produces_different_tokens_each_call
PASSED tests/test_eclipse.py::TestCrypto::test_wrong_key_raises_value_error
PASSED tests/test_eclipse.py::TestCrypto::test_tampered_ciphertext_raises
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_unicode_characters
PASSED tests/test_eclipse.py::TestCrypto::test_master_password_flow
PASSED tests/test_eclipse.py::TestCrypto::test_same_password_derives_same_key
PASSED tests/test_eclipse.py::TestSecureDelete::test_file_is_gone_after_secure_delete
PASSED tests/test_eclipse.py::TestSecureDelete::test_original_content_overwritten
PASSED tests/test_eclipse.py::TestAppFilter::test_blacklist_blocks_matched_app
PASSED tests/test_eclipse.py::TestAppFilter::test_whitelist_allows_matched_app
PASSED tests/test_eclipse.py::TestAppFilter::test_detection_failure_fails_open
PASSED tests/test_eclipse.py::TestStorageEclipse::test_encrypt_item_stores_ciphertext
PASSED tests/test_eclipse.py::TestStorageEclipse::test_decrypt_item_returns_plaintext
PASSED tests/test_eclipse.py::TestStorageEclipse::test_decrypt_wrong_key_returns_none
... 13 more ...
28 passed in 1.84s
chmod +x DotGhostBoard-1.4.0-x86_64.AppImage
./DotGhostBoard-1.4.0-x86_64.AppImage
chmod +x DotGhostBoard-1.4.0-x86_64.AppImage
./DotGhostBoard-1.4.0-x86_64.AppImage
chmod +x DotGhostBoard-1.4.0-x86_64.AppImage
./DotGhostBoard-1.4.0-x86_64.AppImage
sudo dpkg -i dotghostboard_1.4.0_amd64.deb
sudo dpkg -i dotghostboard_1.4.0_amd64.deb
sudo dpkg -i dotghostboard_1.4.0_amd64.deb
git clone https://github.com/kareem2099/DotGhostBoard
cd DotGhostBoard
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python3 main.py
git clone https://github.com/kareem2099/DotGhostBoard
cd DotGhostBoard
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python3 main.py
git clone https://github.com/kareem2099/DotGhostBoard
cd DotGhostBoard
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python3 main.py
sudo apt install xdotool # Kali / Debian / Ubuntu
sudo apt install xdotool # Kali / Debian / Ubuntu
sudo apt install xdotool # Kali / Debian / Ubuntu
v1.5.0 Nexus (planned)
├── 📡 Local network sync (same WiFi)
├── ☁ Optional cloud backup (S3 / Rclone)
├── 📱 QR code share — scan from phone
├── 🔌 REST API mode — localhost for scripts
├── 💻 CLI companion — dotghost push / pop
└── 🧩 Plugin system
v1.5.0 Nexus (planned)
├── 📡 Local network sync (same WiFi)
├── ☁ Optional cloud backup (S3 / Rclone)
├── 📱 QR code share — scan from phone
├── 🔌 REST API mode — localhost for scripts
├── 💻 CLI companion — dotghost push / pop
└── 🧩 Plugin system
v1.5.0 Nexus (planned)
├── 📡 Local network sync (same WiFi)
├── ☁ Optional cloud backup (S3 / Rclone)
├── 📱 QR code share — scan from phone
├── 🔌 REST API mode — localhost for scripts
├── 💻 CLI companion — dotghost push / pop
└── 🧩 Plugin system - Runs lean on Kali Linux with no browser runtime
- Captures text, images, and video paths automatically
- Has a proper security layer — not just a toggle - FramelessWindowHint — no title bar, no close button
- keyPressEvent — Escape key swallowed
- closeEvent — WM close ignored until password accepted - 🔑 Master Password — set / change / remove with current-password verification
- ⏱ Auto-Lock — spinner 0–480 min
- 🛡 App Filter — mode combo + editable process name list - 🐙 GitHub: https://github.com/kareem2099/DotGhostBoard
- 🐛 Issues: https://github.com/kareem2099/DotGhostBoard/issues
- 🖥 OpenDesktop: https://opendesktop.org/p/2353623/
- ⚙️ CI Builds: https://github.com/kareem2099/DotGhostBoard/actions
- 📝 medium: https://medium.com/@freerave
- 💼 LinkedIn: https://www.linkedin.com/in/freerave/