DEV Community

Cover image for Building DotScramble Part 3: Wayland Wars, a Custom File Picker, and Designing AI Evasion
freerave
freerave

Posted on

Building DotScramble Part 3: Wayland Wars, a Custom File Picker, and Designing AI Evasion

From a PyQt6 license crisis to a fully animated PySide6 file browser that doesn't explode on Wayland — plus non-blocking batch processing and designing AI evasion.

Series recap: DotScramble is a desktop privacy tool that blurs, pixelates, and redacts sensitive regions in images. Part 1 covered the core image processing pipeline. Part 2 covered the licensing system using Ed25519 asymmetric signing. This post covers what happened when I tried to make the UI actually good.


The Crisis I Didn't See Coming

I was sitting with two projects — DotScramble and DotGhostBoard — both shipping under Apache 2.0. I had already migrated both once before, from Tkinter to PyQt6, and that migration wasn't optional — three things made Tkinter untenable:

  • Broken RTL rendering — Arabic letters showed up disconnected and reversed. Tkinter's text engine has no real bidi support, and there's no clean fix for it.
  • No real Wayland support — once distros stopped defaulting to X11, window geometry, focus handling, and basic resize behavior in Tkinter became unreliable.
  • Weak drag-and-drop — Tkinter's native DnD is limited and inconsistent across platforms. Dropping a file onto the canvas was a coin flip whether it registered.

PyQt6 fixed all three. Here's the same Controls panel in both: Tkinter on the right, PySide6 (the version that came later, more on that below) on the left.

Tkinter (right) vs PySide6 (left) — same dark theme, completely different rendering engine underneath

So, both projects were now using PyQt6 for the GUI layer. That migration solved the immediate problems. It also planted the seed for a different crisis entirely.

Then I read the license again.

PyQt6 → GPL v3 (copyleft)
Apache 2.0 → permissive

GPL v3 + Apache 2.0 = 💥 license conflict
Enter fullscreen mode Exit fullscreen mode

GPL v3 is copyleft. That means any project that uses PyQt6 must also be GPL v3. Apache 2.0 has different distribution requirements. You can't ship both at once — they're incompatible.

My options:

Option Trade-off
Switch to GPL v3 Lose permissive licensing flexibility
Dual license Complicates contribution model
Migrate to PySide6 LGPL v3 — compatible with Apache 2.0 ✅

PySide6 is the official Qt binding maintained by The Qt Company itself. LGPL v3 means closed-source and commercial use is fine as long as you dynamically link. For a desktop app, that's basically free.

The API difference turned out to be mostly naming:

# PyQt6
from PyQt6.QtCore import pyqtSignal, pyqtSlot
my_signal = pyqtSignal(str)

# PySide6
from PySide6.QtCore import Signal, Slot
my_signal = Signal(str)
Enter fullscreen mode Exit fullscreen mode

The bigger gotcha is strict enums. PyQt6 enforces them everywhere. PySide6 is more lenient — which means some PyQt6 code actually runs stricter than PySide6, so migrating in that direction has fewer surprises than going the other way.

Two projects, about two hours each. Migration done.


The Wayland Window Snap Problem

Once I was on PySide6, I ran into the window management issue I'd been avoiding: snap doesn't work properly on Wayland.

Chromium snaps fine. My Qt app doesn't. Here's why that's not a fair comparison — Chromium uses its own window management layer entirely. Qt apps on Wayland communicate through the compositor protocol, and there are three separate issues hiding inside "snap is broken":

Problem 1: setMaximumWidth on the sidebar kills snap layouts

When the window is snapped to half-screen (~700px), the sidebar demands 350px and leaves the canvas only 330px. The fix is giving it a smaller ceiling:

# Before
self.scroll_area.setMinimumWidth(350)
self.scroll_area.setMaximumWidth(450)  # too rigid

# After
self.scroll_area.setMinimumWidth(260)
self.scroll_area.setMaximumWidth(400)
Enter fullscreen mode Exit fullscreen mode

Problem 2: Canvas resize fires hundreds of times during snap animation

ImageCanvas.resizeEvent calls display_image() on every frame of the snap animation, causing visible flicker. The fix is a debounce timer:

# In __init__
self._resize_timer = QTimer()
self._resize_timer.setSingleShot(True)
self._resize_timer.timeout.connect(self._do_canvas_resize)

def on_canvas_resize(self):
    self._resize_timer.start(60)  # 60ms debounce

def _do_canvas_resize(self):
    if self.processed_image is not None:
        self.display_image(self.processed_image)
    elif self.original_image is not None:
        self.display_image(self.original_image)
    else:
        self.canvas.update()
Enter fullscreen mode Exit fullscreen mode

Problem 3: restore_window_state on Wayland

Window geometry on Wayland isn't available until after the window is actually shown. Calling resize() and move() in __init__ does nothing reliable. The fix is to delay restoration:

def showEvent(self, event):
    super().showEvent(event)
    if not hasattr(self, '_state_restored'):
        self._state_restored = True
        self.restore_window_state()
        self.restore_user_settings()
Enter fullscreen mode Exit fullscreen mode

Also save and restore the splitter state across sessions:

# on close
self.db_manager.save_setting(
    'splitter_state',
    self.splitter.saveState().toHex().data().decode(),
    'window'
)

# on restore
splitter_bytes = self.db_manager.get_setting('splitter_state')
if splitter_bytes:
    from PySide6.QtCore import QByteArray
    self.splitter.restoreState(QByteArray.fromHex(splitter_bytes.encode()))
Enter fullscreen mode Exit fullscreen mode

Note: The KDE/GNOME snap grid that appears when hovering the maximize button is a compositor feature, not something Qt apps can opt into. Meta + arrow key is your friend here.


Replacing the Native File Dialog

The file picker looked like this:

GTK. White. On a dark cyberpunk app. No.

The typical fix is QFileDialog.Option.DontUseNativeDialog — but that gives you the Qt default dialog, which also ignores your stylesheet. I wanted something that actually fits.

So I built one from scratch.

The design

┌─ 📷 Select Image ─────────────── [🔍 Filter] [✕] ─┐
├───────────────────────────────────────────────────────┤
│ QUICK ACCESS  │ Home › Pictures          │  Preview  │
│               │ ┌────┐ ┌────┐ ┌────┐    │  [image]  │
│ 🏠 Home       │ │img │ │img │ │img │    │           │
│ 🖼️ Pictures   │ └────┘ └────┘ └────┘    │  file.jpg │
│ ⬇️ Downloads  │ ┌────┐ ┌────┐           │  1920×1080│
│ 🖥️ Desktop    │ │📁  │ │img │           │  2.3 MB   │
│               │ └────┘ └────┘           │           │
├───────────────────────────────────────────────────────┤
│ /home/kareem/Pictures          [Cancel]    [ Open ]  │
└───────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Our custom PySide6 File Dialog in action — featuring dark mode, quick access bookmarks, staggered animations, and a right-hand preview panel

Three animations:

  1. Dialog opens with a fade-in
  2. File tiles appear with a staggered cascade (each 18ms apart)
  3. Preview panel slides in from the right when you select an image

The Wayland painter trap

My first implementation used QGraphicsOpacityEffect for the tile animations. On Linux, the terminal filled up with this:

QPainter::begin: A paint device can only be painted by one painter at a time.
QWidgetEffectSourcePrivate::pixmap: Painter not active
QPainter::save: Painter not active
...
Enter fullscreen mode Exit fullscreen mode

Hundreds of lines. The dialog still opened, but something was clearly wrong.

Here's what happens: when you put a QGraphicsOpacityEffect on a widget that has a custom paintEvent, Qt's effect pipeline tries to grab the widget's pixels using an internal painter — while your paintEvent is already holding a painter on the same device. On Wayland, this conflict is loud.

The fix: don't use QGraphicsOpacityEffect on widgets with custom paint. Instead, expose opacity as a Property and apply it directly inside the painter:

class FileTile(QWidget):
    # ── opacity as animatable property ─────────────────────────
    def _get_tile_opacity(self): return self._tile_opacity
    def _set_tile_opacity(self, v):
        self._tile_opacity = max(0.0, min(1.0, v))
        self.update()
    tile_opacity = Property(float, _get_tile_opacity, _set_tile_opacity)

    def paintEvent(self, _):
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        # Single painter, opacity baked in — no effect pipeline
        p.setOpacity(self._tile_opacity)
        # ... rest of paint
Enter fullscreen mode Exit fullscreen mode

And in the grid loader, staggered reveal without effects:

for i, tile in enumerate(self._tiles):
    tile._tile_opacity = 0.0
    def _fade(t=tile):
        a = QPropertyAnimation(t, b'tile_opacity', t)
        a.setDuration(200)
        a.setStartValue(0.0)
        a.setEndValue(1.0)
        a.setEasingCurve(QEasingCurve.Type.OutCubic)
        a.start()
        t._reveal_anim = a  # keep ref — GC would kill it otherwise
    QTimer.singleShot(min(i * 18, 300), _fade)
Enter fullscreen mode Exit fullscreen mode

The t._reveal_anim = a line is important. If you don't keep a reference to the QPropertyAnimation, Python's garbage collector kills it mid-animation and the tile stays frozen at whatever opacity it reached.

The dialog open animation on Wayland

QGraphicsOpacityEffect on the container had the same painter conflict. The solution is setWindowOpacity() — compositor-level, no painter involved:

# But first, check if the platform supports it
import os
_is_wayland = (
    os.environ.get('WAYLAND_DISPLAY') is not None or
    QApplication.platformName() == 'wayland'
)

if not _is_wayland:
    self.setWindowOpacity(0.0)
    anim = QPropertyAnimation(self, b'windowOpacity', self)
    anim.setDuration(220)
    anim.setStartValue(0.0)
    anim.setEndValue(1.0)
    anim.setEasingCurve(QEasingCurve.Type.OutCubic)
    anim.start()
Enter fullscreen mode Exit fullscreen mode

Wayland compositors spam This plugin does not support setting window opacity to the terminal otherwise. The dialog appears instantly on Wayland — acceptable behavior.

The QScrollArea double-delete crash

First run:

RuntimeError: libshiboken: Internal C++ object (QWidget) already deleted.
Enter fullscreen mode Exit fullscreen mode

In _load_grid() I was doing:

old = self.grid_widget
self.grid_widget = QWidget()
self.scroll.setWidget(self.grid_widget)
old.deleteLater()  # 💥 Qt already deleted it
Enter fullscreen mode Exit fullscreen mode

From Qt docs: "The widget becomes a child of the scroll area, and will be **destroyed* when a new widget is set."*

setWidget() already deletes the old widget. Calling deleteLater() on an already-deleted C++ object is a crash. The fix: remove those two lines entirely.

Cross-platform bookmarks

The original sidebar was hardcoded for Linux:

BOOKMARKS = [
    ('🏠', 'Home',      str(Path.home())),
    ('🖼️', 'Pictures',  str(Path.home() / 'Pictures')),
    ('⬇️', 'Downloads', str(Path.home() / 'Downloads')),
    ('🖥️', 'Desktop',   str(Path.home() / 'Desktop')),
    ('📂', 'Root',      '/'),  # doesn't exist on Windows
]
Enter fullscreen mode Exit fullscreen mode

On a French Linux system, ~/Desktop might be ~/Bureau. On Windows, C:\Users\kareem\Pictures might be somewhere else entirely. The fix uses XDG on Linux and platform-specific approaches elsewhere:

def _read_xdg_user_dirs() -> dict:
    """Parse ~/.config/user-dirs.dirs — handles localized folder names."""
    result = {}
    config = Path.home() / '.config' / 'user-dirs.dirs'
    if not config.exists():
        return result
    try:
        with open(config) as f:
            for line in f:
                line = line.strip()
                if line.startswith('#') or '=' not in line:
                    continue
                key, _, val = line.partition('=')
                key = key.replace('XDG_', '').replace('_DIR', '')
                val = val.strip().strip('"').replace('$HOME', str(Path.home()))
                if val:
                    result[key] = val
    except Exception:
        pass
    return result


def get_bookmarks() -> list[tuple[str, str, str]]:
    home  = Path.home()
    marks = [('🏠', 'Home', str(home))]

    if sys.platform == 'win32':
        # Windows drives + user folders
        import string
        candidates = [
            ('🖼️', 'Pictures',  os.environ.get('USERPROFILE', str(home)) + '\\Pictures'),
            ('⬇️', 'Downloads', str(home / 'Downloads')),
            ('🖥️', 'Desktop',   str(home / 'Desktop')),
            ('📄', 'Documents', os.environ.get('DOCUMENTS', str(home / 'Documents'))),
        ]
        for icon, label, path in candidates:
            if os.path.exists(path):
                marks.append((icon, label, path))
        for letter in string.ascii_uppercase:
            drive = f'{letter}:\\'
            if os.path.exists(drive):
                marks.append(('💾', f'{letter}:', drive))

    elif sys.platform == 'darwin':
        candidates = [
            ('🖼️', 'Pictures',  home / 'Pictures'),
            ('⬇️', 'Downloads', home / 'Downloads'),
            ('🖥️', 'Desktop',   home / 'Desktop'),
            ('📂', 'Volumes',   Path('/Volumes')),
        ]
        for icon, label, path in candidates:
            if Path(path).exists():
                marks.append((icon, label, str(path)))

    else:
        # Linux — XDG first, fallback to English names
        xdg = _read_xdg_user_dirs()
        candidates = [
            ('🖼️', 'Pictures',  xdg.get('PICTURES',  str(home / 'Pictures'))),
            ('⬇️', 'Downloads', xdg.get('DOWNLOAD',  str(home / 'Downloads'))),
            ('🖥️', 'Desktop',   xdg.get('DESKTOP',   str(home / 'Desktop'))),
            ('📄', 'Documents', xdg.get('DOCUMENTS', str(home / 'Documents'))),
            ('🎵', 'Music',     xdg.get('MUSIC',     str(home / 'Music'))),
            ('📂', 'Root',      '/'),
        ]
        for icon, label, path in candidates:
            if os.path.exists(path):
                marks.append((icon, label, path))

    return marks
Enter fullscreen mode Exit fullscreen mode

Pyrefly IDE users: The win32 and darwin branches will appear dimmed/dead on Linux because Pyrefly does platform narrowing — static analysis treats unreachable-on-current-platform code as dead. The code is correct. Add _PLATFORM = sys.platform and check against that variable to suppress it, or set platform = "all" in pyrefly.toml.

Thumbnail loading

Thumbnails load in a QThreadPool so scrolling stays smooth:

class ThumbLoader(QRunnable):
    def run(self):
        try:
            from PIL import Image
            img = Image.open(self.path)
            img.thumbnail((140, 140), Image.LANCZOS)
            img  = img.convert('RGBA')
            data = img.tobytes('raw', 'RGBA')
            qimg = QImage(data, img.width, img.height,
                          QImage.Format.Format_RGBA8888)
            qimg._pil_data = data   # keep alive until QImage is done
            self.signals.done.emit(self.path, QPixmap.fromImage(qimg))
        except Exception:
            pass
Enter fullscreen mode Exit fullscreen mode

Using Pillow instead of QImage directly avoids qt.gui.imageio.jpeg: Corrupt JPEG data warnings for slightly malformed JPEGs — Pillow is more lenient and the visual difference is zero.


Multithreading in Qt: Non-Blocking Batch Processing

Adding a batch processing feature poses a major UI challenge. If you try to process 100 images sequentially on the main GUI thread, the Qt event loop locks up. The application window freezes, visual elements stop repainting, and the operating system eventually prompts the user to force-close the "Not Responding" application.

To solve this, the processing must happen asynchronously in the background. But there is a catch: Qt widgets are not thread-safe. You cannot modify GUI elements (like updating a progress bar or changing a label's text) directly from a background worker thread without triggering random segfaults.

Thread-Safe Communication via Custom Signals

The standard way to bridge this gap in PySide6 is using custom Signals. Since PySide6 signals are inherently thread-safe when using a queued connection, emitting a signal from a worker thread safely marshals the event back to the main thread's event loop.

First, we define a small QObject to hold our signals:

class BatchSignals(QObject):
    """Signals for thread-safe UI updates from the processing worker thread"""
    progress = Signal(int, int, dict)
    error = Signal(str, str)
    complete = Signal()
Enter fullscreen mode Exit fullscreen mode

Inside our dialog initialization, we connect these signals to the GUI update slots:

self.signals = BatchSignals()
self.signals.progress.connect(self.on_progress)
self.signals.error.connect(self.on_error)
self.signals.complete.connect(self.on_complete)
Enter fullscreen mode Exit fullscreen mode

The Worker Thread Lifecycle

When the user clicks "Start", we launch a standard Python threading.Thread as a daemon thread. We pass nested callbacks that do nothing but emit our thread-safe signals:

def start_processing(self):
    # ... read UI values and settings ...
    self.processing = True
    self.update_ui_state(processing=True)

    def process_worker():
        def progress_cb(current, total, result):
            self.signals.progress.emit(current, total, result)

        def error_cb(file_path, error_msg):
            self.signals.error.emit(file_path, error_msg)

        self.batch_processor.process_batch(
            valid_files,
            output_dir,
            settings,
            progress_callback=progress_cb,
            error_callback=error_cb,
            is_cancelled=lambda: not self.processing
        )
        self.signals.complete.emit()

    thread = threading.Thread(target=process_worker, daemon=True)
    thread.start()
Enter fullscreen mode Exit fullscreen mode

Graceful Cancellation and License Gating

The is_cancelled parameter passes a lambda lambda: not self.processing down to the batch loop. Inside the processing loop, BatchProcessor checks this condition on every iteration:

for i, input_path in enumerate(input_paths):
    if is_cancelled and is_cancelled():
        break  # Exit early if the user clicked "Stop"
    # ... process image ...
Enter fullscreen mode Exit fullscreen mode

Additionally, because batch processing is a resource-intensive "PRO" feature, we gate the GUI. If the license manager detects that the Max tier is not activated, the window restricts input selection:

FREE_FILE_LIMIT = 3

def _is_max(self):
    return self.license_manager.is_max_activated

# When adding files:
if not self._is_max() and len(self.selected_files) > self.FREE_FILE_LIMIT:
    QMessageBox.warning(self, "Limit Reached", "Free tier is limited to 3 files.")
    # ... slice list to limit ...
Enter fullscreen mode Exit fullscreen mode

This keeps the GUI perfectly responsive, gives the user real-time feedback with zero crashes, and ties in the licensing system we built in Part 2.


What's Next: Designing AI Evasion (Adversarial Perturbation)

DotScramble can now blur, pixelate, or black out sensitive regions like faces. While that visual degradation is enough to prevent a human from recognizing the subject, it turns out it is not enough for modern AI-driven facial recognition systems.

The Threat Model: AI Face Recognition

Deep learning models extract abstract feature vectors from faces. Even if a face is heavily blurred or partially pixelated, the neural network can still reconstruct or match the underlying features with surprisingly high confidence. In my tests, a blurred face run through an open-source face recognition model still yielded a cosine similarity match of over 0.7 against the original unblurred image—well above typical detection thresholds.

To achieve true privacy in the age of scraping pipelines and surveillance, DotScramble needs a second line of defense: adversarial perturbations.

Fooling the Neural Network

Adversarial machine learning involves adding a mathematically optimized, imperceptible noise pattern to an image. To a human, the image looks virtually unchanged, but to a neural network, the features are completely scrambled, leading to an incorrect classification or a failed match:

Original image+ε×noise=Adversarial example (gibbon/unrecognized) \text{Original image} + \varepsilon \times \text{noise} = \text{Adversarial example (gibbon/unrecognized)}

The Black-Box Challenge (SPSA)

Most standard adversarial attack methods (like FGSM) are "white-box" attacks—they require full access to the target model's architecture, weights, and gradients so you can perform backpropagation.

For DotScramble, we don't know what model the end-user (or scraper) will use. We need a black-box attack. To solve this, we can design an estimation engine using Simultaneous Perturbation Stochastic Approximation (SPSA). SPSA approximates gradients by evaluating the loss function at just two perturbed points per iteration, regardless of parameter count:

g^(θ)L(θ+cΔ)L(θcΔ)2c×Δ1 \hat{g}(\theta) \approx \frac{L(\theta + c\Delta) - L(\theta - c\Delta)}{2c} \times \Delta^{-1}

This allows us to optimize adversarial perturbations on the fly using a local proxy model, ensuring the face remains unrecognizable to AI systems.


Bugs I Hit and What They Actually Were

Error Looked Like Actually Was
RuntimeError: Internal C++ object already deleted Memory bug deleteLater() on widget Qt already deleted via setWidget()
QPainter: A paint device can only be painted by one painter at a time Concurrent threads QGraphicsOpacityEffect + custom paintEvent on same widget
This plugin does not support setting window opacity Qt bug setWindowOpacity() not supported on Wayland compositor
Corrupt JPEG data: 1 extraneous bytes before marker 0xd9 Broken images Cameras that write slightly nonstandard JPEG — images load fine
Sidebar items dimmed in IDE Code wrong Pyrefly platform-narrowing dead code detection

Key Takeaways

On Wayland: Don't use QGraphicsOpacityEffect on widgets with custom paintEvent. The effect pipeline creates a painter conflict. Use p.setOpacity() inside your custom paint instead.

On Qt object lifetime: QScrollArea::setWidget() destroys the previous widget. Don't call deleteLater() on it afterward. Same pattern appears in QStackedWidget, QDockWidget, and others.

On file dialogs: The native file dialog on Linux is GTK and ignores your stylesheet. Building a custom one is maybe 400 lines and gives you full control over layout, animations, and behavior — worth it for a premium feel.

On Qt multithreading: Never update GUI elements directly from a background thread; it leads to memory corruption and instant crashes. Use QObject with Signals to safely bridge communication from the worker thread to the main GUI thread.

On adversarial perturbations: Blurring or pixelating a face might fool a human, but modern neural networks can still recognize it. Adversarial noise slightly alters pixels to break the model's feature extraction while keeping the image recognizable to humans.

On black-box optimization: When you don't have access to model weights or gradients, black-box optimization algorithms like SPSA (Simultaneous Perturbation Stochastic Approximation) let you estimate gradients efficiently with just a few forward passes.


DotScramble is part of DotSuite — a collection of developer privacy tools. Part 4 covers the AI Evasion system — why blurring alone isn't enough against modern face recognition, and how SPSA black-box adversarial perturbations add a second layer of protection that works without access to any model weights.

GitHub (v1.4.1) · OpenDesktop · Ko-fi · Buy Me a Coffee

Top comments (0)