I promised I'd fix it. Here's exactly what broke in DotGhostBoard after v1.5.1 — PyInstaller frozen environments, insecure /tmp update paths, missing UI assets, and a polkit regression — and how each one was resolved.
This is a follow-up to Engineering the Nexus Release. At the end of that article I wrote: "Stay tuned for v2.0.0 Cerberus." Before Cerberus ships, there's something I owe you — the full post-mortem on what broke in production immediately after that release.
Check out the v1.5.3 highlight reel to see the new Live Terminal and Network Sync in action:
What Happened After v1.5.1 Shipped
The Nexus architecture held. mDNS discovery worked. E2EE pairing worked. The sync engine worked.
What didn't work: the UI didn't load on any bundled build.
Users who downloaded the .AppImage or .deb were greeted with a white window. No stylesheet. No dark theme. Just a blank Qt frame. The app was running — but blind.
That was the beginning of the v1.5.2 → v1.5.3 hotfix sprint.
Bug 1 — The White Window (PyInstaller Frozen Environment)
Root Cause
When PyInstaller bundles an app, it doesn't just copy files — it extracts them into a temporary directory at runtime called sys._MEIPASS. The problem is that code like this:
# ❌ Breaks in bundled builds
base = os.path.dirname(__file__)
qss_path = os.path.join(base, "ui", "ghost.qss")
...works perfectly when you run python main.py from source, because __file__ points to the project root. But inside a frozen .AppImage, __file__ resolves to a virtual path inside the bundle — and ghost.qss is nowhere near it.
Source mode: __file__ → /home/user/DotGhostBoard/core/app.py ✅
Frozen mode: __file__ → /tmp/_MEI3xk9/core/app.py ✅
ghost.qss → ??? ❌
The UI stylesheet ghost.qss simply couldn't be found. Qt silently fell back to its default theme. White window.
The Fix: resource_path Bridge
# core/paths.py
import os
import sys
def _base_dir() -> str:
"""
Detect whether we're running from source or a PyInstaller bundle.
In frozen mode, all assets are extracted to sys._MEIPASS at runtime.
"""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return sys._MEIPASS # PyInstaller extraction folder
return os.path.dirname( # two levels up from core/paths.py
os.path.dirname(os.path.abspath(__file__))
)
def resource_path(*parts: str) -> str:
"""
Build an absolute path to any bundled asset, regardless of runtime mode.
Usage:
resource_path("ui", "ghost.qss") → /tmp/_MEIxxxx/ui/ghost.qss
resource_path("data", "icon.png") → /home/user/Project/data/icon.png
"""
return os.path.join(_base_dir(), *parts)
Decision flow:
App requests: resource_path("ui", "ghost.qss")
│
is_frozen?
/ \
Yes No
│ │
sys._MEIPASS Project Root
│ │
/tmp/_MEIxxxx/ui/ghost.qss /home/user/.../ui/ghost.qss
Every asset reference in the codebase was updated to go through resource_path(). The white window was gone.
The CI Side of This Bug
But wait — local builds worked. CI builds didn't. Why?
Because the PyInstaller command in the workflow was missing the --add-data directive for the UI directory:
# ❌ v1.5.1 — ghost.qss never made it into the bundle
- name: Build
run: |
pyinstaller --noconsole --onedir \
--add-data "data:data" \
--hidden-import "PyQt6.sip" \
--name dotghostboard main.py
# ✅ v1.5.3 — explicitly map the UI directory
- name: Build
run: |
pyinstaller --noconsole --onedir \
--add-data "data:data" \
--add-data "ui/ghost.qss:ui" \ # ← this line was the missing piece
--hidden-import "PyQt6.sip" \
--hidden-import "cryptography" \
--hidden-import "cryptography.hazmat.primitives.asymmetric.x25519" \
--hidden-import "cryptography.hazmat.primitives.ciphers.aead" \
--name dotghostboard main.py
The --add-data syntax is "source:dest" — it maps the source path on the build machine to the relative path inside _MEIPASS. Without it, PyInstaller doesn't know the file exists and silently skips it.
The deceptive part: local
pyinstallerbuilds work becausepyinstallerfindsui/ghost.qssrelative to the working directory. The GitHub Actions runner has a clean checkout with the same structure — but without explicit--add-data, the file is excluded from the bundle spec. Same command, different behavior depending on how PyInstaller resolves paths in its analysis phase.
You can verify any .deb build has the file:
dpkg -c dotghostboard_1.5.3_amd64.deb | grep ui/
# Expected: ... _internal/ui/ghost.qss
Bug 2 — Insecure Update Download Path
In v1.5.1, the in-app "Download Update" feature used /tmp/ as the staging area:
# ❌ v1.5.1
download_path = f"/tmp/dotghostboard_{version}_amd64.deb"
/tmp/ is world-readable, world-writable, and wiped on reboot. For a one-time download that's fine. For a security tool that downloads and installs packages — it's a TOCTOU (Time-Of-Check to Time-Of-Use) attack surface. An attacker with local access could replace the .deb between download and installation.
The Fix: User-Specific Sandbox
# ✅ v1.5.2+
import os
def get_update_staging_dir() -> str:
"""
Returns a user-specific, restricted directory for staging update packages.
Created with mode 0o700 — only the owning user can read or write.
"""
path = os.path.join(
os.path.expanduser("~"),
".local", "share", "dotghostboard", "updates"
)
os.makedirs(path, mode=0o700, exist_ok=True)
return path
SAFE_PATH = os.path.join(get_update_staging_dir(), f"dotghostboard_{version}_amd64.deb")
| v1.5.1 | v1.5.2+ | |
|---|---|---|
| Download path | /tmp/ |
~/.local/share/dotghostboard/updates/ |
| Readable by other users | Yes | No (0o700) |
| Survives reboot | No | Yes |
| Integrity check | None | SHA-256 (v1.5.3) |
The Kamikaze Installer
The install script itself also needed hardening. After dpkg -i finishes, no artifacts should remain:
#!/bin/sh
# dotghostboard_install.sh — self-destructs after use
SAFE_PATH="$1"
dpkg -i "$SAFE_PATH" # Install the package
rm -f "$SAFE_PATH" # Wipe the .deb from disk
rm -f "$0" # Wipe this script itself
Nothing lingers. No old .deb sitting in a directory. No script to be re-executed.
Bug 3 — Polkit SUID Regression on Kali
Kali Linux (and some hardened Arch setups) occasionally strip or reset the SUID bit on /usr/bin/pkexec after system updates. This caused the in-app "Download & Install" button to fail silently — pkexec would return a permission error, and the UI would just hang.
The fix was embedded directly in the package's postinst script — it runs as root immediately after dpkg -i completes:
#!/bin/sh
# DEBIAN/postinst
set -e
fix_pkexec() {
PKEXEC="/usr/bin/pkexec"
if [ -f "$PKEXEC" ]; then
CURRENT_PERMS=$(stat -c "%a" "$PKEXEC")
if [ "$CURRENT_PERMS" != "4755" ]; then
echo "dotghostboard: repairing pkexec SUID bit ($CURRENT_PERMS → 4755)"
chmod 4755 "$PKEXEC"
fi
fi
}
case "$1" in
configure)
fix_pkexec
;;
esac
exit 0
Logic flow:
dpkg -i dotghostboard_1.5.3_amd64.deb
│
└── postinst runs as root
│
/usr/bin/pkexec exists?
│
current perms == 4755?
/ \
Yes No
│ │
Skip chmod 4755
│
"Download & Install" works
This is controversial — touching SUID bits in a postinst script is aggressive. But the alternative is users filing issues that are impossible to debug remotely, or writing a setup guide that says "run chmod 4755 /usr/bin/pkexec after installing." The autorepair is the lesser evil.
Feature: Live Terminal Log (v1.5.3)
While fixing the above bugs, I replaced the old "Please Wait..." dialog during updates with a live terminal stream. It felt wrong to show a spinner while dpkg was doing real work with real output.
Architecture
# ui/update_log_screen.py
from PyQt6.QtCore import QThread, pyqtSignal
import subprocess
class InstallWorker(QThread):
log_line = pyqtSignal(str) # emitted for every line of dpkg output
finished = pyqtSignal(int) # exit code
def __init__(self, script_path: str):
super().__init__()
self.script_path = script_path
def run(self):
process = subprocess.Popen(
["bash", self.script_path],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # merge stderr into stdout
text=True,
bufsize=1 # line-buffered
)
for line in process.stdout:
self.log_line.emit(line.rstrip())
process.wait()
self.finished.emit(process.returncode)
On the UI side:
# Color mapping — Regex-based, no AI required
import re
from PyQt6.QtGui import QColor
COLOR_MAP = [
(re.compile(r'\berror\b', re.I), "#FF5555"), # red
(re.compile(r'\bwarning\b', re.I), "#FFB86C"), # amber
(re.compile(r'\bSetting up\b|\bProcessing\b'), "#50FA7B"), # green
(re.compile(r'^--'), "#8BE9FD"), # cyan for steps
]
def colorize(line: str) -> str:
for pattern, color in COLOR_MAP:
if pattern.search(line):
return f'<span style="color:{color}">{line}</span>'
return line # default — white
def on_log_line(self, line: str):
self.log_box.append(colorize(line))
# Auto-scroll to bottom
sb = self.log_box.verticalScrollBar()
sb.setValue(sb.maximum())
The blinking cursor is driven by a QTimer — no threads, no complexity:
self._cursor_visible = True
self._cursor_timer = QTimer()
self._cursor_timer.timeout.connect(self._blink_cursor)
self._cursor_timer.start(500) # 500ms interval
def _blink_cursor(self):
self._cursor_visible = not self._cursor_visible
self.cursor_label.setText("█" if self._cursor_visible else " ")
Verification Checklist
For any future hotfix, these three commands confirm the build is clean:
# 1. Confirm ghost.qss is inside the bundle
dpkg -c dotghostboard_1.5.3_amd64.deb | grep ui/
# Expected: ./opt/dotghostboard/_internal/ui/ghost.qss
# 2. Verify SUID bit post-install
ls -l /usr/bin/pkexec
# Expected: -rwsr-xr-x (the 's' in position 4 is the SUID bit)
# 3. Test resource_path in frozen mode (AppImage)
./DotGhostBoard-1.5.3-x86_64.AppImage --debug-paths
# Should print resolved paths for ghost.qss and icon_256.png
What I Learned
PyInstaller's --add-data is not optional for anything outside data/. If your app loads a file at runtime that isn't in the directory you passed to --add-data, it won't be in the bundle. PyInstaller doesn't warn you. The app just crashes or falls back silently. Map every resource directory explicitly.
sys._MEIPASS is the only reliable truth in a frozen environment. Don't use __file__, don't use os.getcwd(), don't use relative paths. _MEIPASS is where PyInstaller extracted everything — it's the only path you can trust.
/tmp/ is fine for scratch. It's not fine for security-sensitive staging. The threat model for a clipboard manager that stores secrets is different from a text editor. The update pipeline needed to match that model.
Local builds working ≠ CI builds working. The ghost.qss bug existed for the entire v1.5.x series in bundled form because nobody had tested a clean AppImage build from the CI artifact — only local PyInstaller runs. Add artifact smoke testing to your release checklist.
What's Next — v2.0.0 Cerberus
The hotfix series is closed. v1.5.3 is stable.
Next up is Cerberus — the Zero-Knowledge Password Vault. The AES-256 foundation from v1.4.0 (Eclipse) is already in place. The design is locked:
- Isolated
vault.db— separate file, separate connection, locked when idle - Pattern-based secret detection — JWT, AWS keys (
AKIA...), GitHub tokens (ghp_...), high-entropy hex — shape-based, not keyword-based - Auto-clear: wipes clipboard 30 seconds after a Vault paste
- Paranoia Mode: suspends all DB writes on demand
The core principle of Cerberus: a 1500-word article that contains the word "password" doesn't trigger detection. A 40-character base64 string with high Shannon entropy does.
Download
| Link | |
|---|---|
| 📦 GitHub Release (AppImage + DEB + GPG sigs) | v1.5.3 |
| 🖥️ OpenDesktop | DotGhostBoard |
| 💻 Source | kareem2099/DotGhostBoard |
DotSuite — built for the shadows 👻
Top comments (0)