Developing Linux GUI Apps in WSL: From Script Windows to Maintainable Tools
· 34 min read · Views --

Developing Linux GUI Apps in WSL: From Script Windows to Maintainable Tools

Author: Alex Xiang


Developing Linux GUI programs on Windows used to feel heavy. You either started a virtual machine, configured an X Server, or gave up on the interface and wrote a command-line tool.

WSLg makes this ordinary. Start a Qt, GTK, or OpenCV window inside WSL, and it appears on the Windows desktop like a normal application. You can Alt+Tab, copy and paste, and place it next to your browser and editor.

That does not mean WSL is the right publishing target for every desktop application. It is, however, very good for a specific category: small tools for yourself or an engineering team.

Developing small Linux GUI tools inside WSL

This article follows a reproducible small utility: choose an image directory, generate thumbnails and compressed versions, and preview the result. It can start as a shell plus Python script, then grow into a small GUI.

Verify the GUI Path Before Installing Frameworks

Microsoft’s WSL GUI documentation states that WSL can run X11 and Wayland Linux GUI applications on Windows and integrate them with the Windows desktop. The WSLg project has the same goal: integrated Linux GUI apps on Windows.

Before writing an app, do a minimal test:

sudo apt update
sudo apt install -y x11-apps
xeyes

Then test an image window:

sudo apt install -y feh
feh sample.png

If these do not appear, do not blame Qt or GTK yet. Update WSL first:

wsl --update
wsl --shutdown

Enter Ubuntu again and retry.

Check environment variables:

echo "$DISPLAY"
echo "$WAYLAND_DISPLAY"
ls -la /mnt/wslg

The point is to prove that the WSLg base path works before introducing an application framework.

Keep the First Requirement Small

The tool is called thumb-lab, and the goal is small:

  1. choose an image directory;
  2. show the number of images;
  3. generate thumbnails with width 640;
  4. open the output directory when done;
  5. show an error message on failure.

Start with a command-line version:

mkdir -p ~/work/thumb-lab
cd ~/work/thumb-lab
python3 -m venv .venv
source .venv/bin/activate
python -m pip install pillow

thumb.py:

from pathlib import Path
from PIL import Image
import sys

src = Path(sys.argv[1]).expanduser().resolve()
out = Path(sys.argv[2]).expanduser().resolve()
out.mkdir(parents=True, exist_ok=True)

for path in src.glob("*"):
    if path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
        continue
    img = Image.open(path)
    img.thumbnail((640, 640))
    img.save(out / f"{path.stem}.jpg", quality=88)

print(out)

Run:

python thumb.py ~/Pictures/raw ~/Pictures/thumbs
explorer.exe "$(wslpath -w ~/Pictures/thumbs)"

Make the core logic a pure function or CLI script first. That is the basis for a maintainable GUI. Do not put image-processing logic directly inside button callbacks.

An image batch-processing tool evolves from CLI script to GUI while core logic stays independent

I prefer CLI first, GUI second. The CLI version can quickly validate input, output, error handling, and performance in the terminal. The GUI only selects files, displays progress, and triggers work. If you later need a Web version, batch version, or scheduled job, the core does not need to be rewritten.

A Minimal PySide6 Window

Qt for Python documentation describes PySide6 as the official Python bindings for Qt6. It is suitable for slightly richer desktop tools: buttons, lists, tables, progress bars, file selection, and previews.

Install:

source .venv/bin/activate
python -m pip install PySide6 pillow

app.py:

from pathlib import Path
import subprocess
import sys

from PIL import Image
from PySide6.QtWidgets import (
    QApplication,
    QFileDialog,
    QLabel,
    QPushButton,
    QVBoxLayout,
    QWidget,
)


def make_thumbnails(src: Path, out: Path) -> int:
    out.mkdir(parents=True, exist_ok=True)
    count = 0
    for path in src.glob("*"):
        if path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
            continue
        img = Image.open(path)
        img.thumbnail((640, 640))
        img.convert("RGB").save(out / f"{path.stem}.jpg", quality=88)
        count += 1
    return count


class ThumbLab(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Thumb Lab")
        self.resize(420, 180)

        self.status = QLabel("Choose an image directory")
        self.pick_button = QPushButton("Choose directory and generate thumbnails")
        self.pick_button.clicked.connect(self.pick_and_run)

        layout = QVBoxLayout()
        layout.addWidget(self.status)
        layout.addWidget(self.pick_button)
        self.setLayout(layout)

    def pick_and_run(self):
        directory = QFileDialog.getExistingDirectory(self, "Choose image directory")
        if not directory:
            return

        src = Path(directory)
        out = src / "_thumbs"
        try:
            count = make_thumbnails(src, out)
        except Exception as exc:
            self.status.setText(f"Failed: {exc}")
            return

        self.status.setText(f"Done: generated {count} thumbnails at {out}")
        subprocess.run(["explorer.exe", str(out)], check=False)


app = QApplication(sys.argv)
window = ThumbLab()
window.show()
sys.exit(app.exec())

Run:

python app.py

The window appears on the Windows desktop. If the selected directory is inside the WSL filesystem, processing is steadier. If it is under /mnt/c/Users/..., it still works, but reading many files crosses the boundary.

Do Not Block the GUI Thread

The code above has an obvious problem: when many images are processed, the window freezes. For a tiny tool this may be acceptable, but once work takes more than a few seconds, move it to a worker thread.

In Qt, QThread is the normal tool. The structure matters more than the exact code here:

UI thread:
  - respond to buttons
  - update status
  - show progress

Worker thread:
  - scan files
  - read images
  - generate thumbnails
  - emit progress signals

This is the biggest difference between GUI programs and command-line scripts. A CLI script can block. A blocked window makes users think the program has died.

If the tool is only for one-off internal use, the synchronous version may be enough. Once colleagues use it, progress bars, cancel buttons, and error messages are not decoration.

Keep the GUI thread responsive while a worker handles slow work and reports progress

This model fits most internal GUI tools: the main thread handles window events and lightweight state; the worker performs slow tasks; progress flows through signals or a queue. Do not make button callbacks scan directories, compress images, or call models directly.

GTK Fits More Linux-flavored Tools

If you prefer the GNOME/Linux ecosystem, look at PyGObject. GTK’s Python binding documentation covers GTK, GStreamer, WebKitGTK, GLib, GIO, and related libraries.

Install GTK4 packages in Ubuntu:

sudo apt install -y python3-gi gir1.2-gtk-4.0

Minimal window:

import gi

gi.require_version("Gtk", "4.0")
from gi.repository import Gtk


class App(Gtk.Application):
    def do_activate(self):
        window = Gtk.ApplicationWindow(application=self)
        window.set_title("GTK on WSL")
        window.set_default_size(360, 120)

        label = Gtk.Label(label="Hello from GTK running in WSL")
        window.set_child(label)
        window.present()


app = App(application_id="com.example.ThumbLab")
app.run()

GTK is closer to the Linux desktop ecosystem. Qt is cross-platform, mature, and widely documented. For internal tools, I choose whichever the team knows better instead of arguing about which one is more “proper.”

OpenCV Windows Are for Algorithm Debugging

Image algorithm development often uses:

import cv2

img = cv2.imread("sample.jpg")
cv2.imshow("preview", img)
cv2.waitKey(0)
cv2.destroyAllWindows()

Under WSLg, this window can also appear on the Windows desktop.

It is excellent for:

  • checking binarization results;
  • inspecting detection boxes;
  • viewing OCR crops;
  • comparing preprocessing output;
  • debugging camera or video frames.

But it is not a product UI. Buttons, lists, layouts, async tasks, and error messages are not what OpenCV windows are good at.

My split is:

algorithm debugging: OpenCV window
internal small tool: PySide6 / GTK
ordinary users: native Windows app or Web app

File Paths Are the Easy Trap

When choosing files inside a WSL GUI window, the paths are Linux paths:

/home/alex/Pictures/raw
/mnt/c/Users/Alex/Pictures

But users may think in Windows paths:

C:\Users\Alex\Pictures

If the tool needs to hand output to a Windows program, use:

wslpath -w ~/Pictures/thumbs

In Python:

import subprocess
from pathlib import Path

def open_in_explorer(path: Path) -> None:
    subprocess.run(["explorer.exe", str(path)], check=False)

explorer.exe can usually accept a WSL path and open the corresponding directory. Team tools should show the output directory clearly and provide an “Open folder” button.

Do not make users guess between /home, \\wsl.localhost, and /mnt/c.

Packaging: Do Not Over-engineer Internal Tools

Developing a GUI in WSL does not mean it must be distributed through WSL.

For internal engineering tools, this may be enough:

git clone ...
cd thumb-lab
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py

Add a launch script:

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
source .venv/bin/activate
python app.py

For colleagues familiar with WSL, this is fine.

For non-technical colleagues, evaluate again:

  • build a Web page;
  • build a native Windows app;
  • integrate it into an internal platform;
  • package it formally with Electron, Tauri, .NET, or Qt.

WSL GUI is good for development and internal efficiency tools. It is not a shortcut around proper distribution.

Tradeoffs among internal WSL tools, Web tools, and native Windows applications

My rule is simple: for engineers only, WSL GUI is fine; for operations, finance, or product colleagues, prefer Web; for offline use, system integration, or long-term delivery, consider native Windows packaging. WSL GUI is a development efficiency tool, not the final form of every desktop app.

Debugging Checklist

When a GUI program does not start, I check in this order:

# 1. WSLg baseline
xeyes

# 2. Environment variables
echo "$DISPLAY"
echo "$WAYLAND_DISPLAY"

# 3. Python environment
which python
python -V
python -c "import PySide6; print('PySide6 ok')"

# 4. Shared libraries
ldd "$(python -c 'import PySide6, pathlib; import PySide6.QtCore as c; print(c.__file__)')" 2>/dev/null | head

# 5. Path
pwd
df -T .

If xeyes does not work, fix WSLg first.
If xeyes works but PySide6 does not, inspect Python packages.
If the program starts but processing is slow, check whether files live under /mnt/c.

Layered debugging is more useful than staring at a long traceback.

Summary

WSLg brings Linux GUI applications to the Windows desktop. For developers, its value is not turning Windows into a Linux desktop.

It is better for this workflow:

Write the core logic as a script first.
When the script is stable, add a small window.
The window selects files, shows progress, and opens results.
When more people need it, decide whether it should become Web or native Windows.

With that approach, GUI development inside WSL stays light and does not become another maintenance burden.

References