DEV Community

Cover image for The Hotfix Chronicles: What Broke After the Nexus Release and How I Fixed It (v1.5.2 & v1.5.3)
freerave
freerave

Posted on

The Hotfix Chronicles: What Broke After the Nexus Release and How I Fixed It (v1.5.2 & v1.5.3)

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")
Enter fullscreen mode Exit fullscreen mode

...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 → ???                                   ❌
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# ✅ 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
Enter fullscreen mode Exit fullscreen mode

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 pyinstaller builds work because pyinstaller finds ui/ghost.qss relative 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

/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")
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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 " ")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)