ProtonShift is a Qt Quick (QML) desktop app whose UI is backed by Python. There
is no HTTP layer, no bundled browser, and no IPC protocol to version - the
"API" between Python and QML is a set of Qt QObjects exposed as context
properties, using ordinary Property/Signal/Slot bindings.
protonshift/
core/ pure-Python domain logic - no Qt imports, no QML awareness
controllers/ QObject bridges: expose core/ to QML via Property/Signal/Slot
qml/
App/ design-system module: Theme.qml + Ps* components
*.qml the 8 top-level pages + main.qml (window/nav) + Splash.qml
app.py entry point: builds the QML engine, wires controllers in
flatpak/ packaging manifests (see "Packaging" below)
tests/ pytest suite (core-only, no Qt runtime required)
Everything under protonshift/core/ is plain Python: file I/O, subprocess
calls, and parsing, with no PySide6 import anywhere in the package. This is
deliberate - it's what makes tests/ fast and Qt-free, and it's what a
controller threads off the GUI thread (see below).
Roughly one module per integration or feature:
- Discovery:
steam.py,steam_shortcuts.py(Non-Steam shortcuts from the binaryshortcuts.vdf),heroic.py(Epic, GOG, Amazon, sideloaded),heroic_config.py,lutris.py- find installed games and their config files across native and Flatpak installs of each launcher. - Config editors:
gamescope.py(command building),mangohud.py,scopebuddy.py,env_vars.py,vdf_config.py(Steam launch options),launch_merge.py(places a snippet around%command%instead of appending it). - System:
display.py(xrandr/wlr-randr/kscreen-doctor),gpu.py(nvidia-smi/sysfs + power profiles),input_devices.py(gamepad detection, SDL mapping, force-feedback rumble via rawioctl/structpacking),host.py(host command execution, see "Flatpak sandboxing" below). - Game maintenance:
shader_cache.py,saves.py(backup/restore),prefix.py,protontricks.py,fixes.py(known-fixes DB, backed bycore/data/known_fixes.json),launch_presets.py,profiles_storage.py,compat_tool.py,launch_check.py(why a game won't start; pure, fed by its controller),proton_log.py(tail ofsteam-<appid>.log). - The app itself:
appearance.py(styles, mode, accent),sounds.py(sound sets and stored settings),updates.py(manual update check). - Shared low-level helpers:
fsutil.py(atomic writes, dir sizing),paths.py(path containment / filename sanitization - see CLAUDE.md for the security note on this),tool_check.py(which-style tool detection),host.py.
Each controller is a QObject subclass exposed to QML as a context property
in app.py (e.g. GamesController → library, GamescopeController →
gamescope). A controller's job is narrow: translate QML-friendly
Property/Signal/Slot calls into core/ function calls, and translate the
results back into QML-friendly types (QVariantList, QVariantMap, plain
str/bool/int).
Anything in core/ that touches disk or spawns a subprocess is blocking, and
blocking the GUI thread freezes the window. Controllers push that work onto a
worker thread and report back through a queued Qt signal, using the shared
helper in protonshift/controllers/_worker.py:
start_worker(self._work, on_error=self._workError.emit)start_worker runs target on a daemon threading.Thread. Any exception
raised inside target - expected or not - is caught and routed to
on_error as a short string, rather than silently killing the thread. Before
this existed, an unexpected exception in a worker body could kill the thread
before its result signal fired, permanently wedging the controller's
loading/busy flag with no way to recover short of restarting the app.
The typical controller shape (see controllers/games_controller.py for a full
example):
- A public
Slot()(e.g.refresh()) sets_loading = True, emitsloadingChanged, and callsstart_worker(self._work, ...). _work()runs on the worker thread, calls intocore/, and emits a privateSignal(e.g._resultReady) carrying the result.- Because the signal was connected with
QObject.connectfrom the GUI thread and Qt's default connection type resolves toQueuedConnectionacross threads, the connected slot (_on_result) runs back on the GUI thread - safe to mutate state QML is bound to and to emit*Changedsignals from. - A second private signal (
_workError) is connected to a handler that clearsloadingand surfaces the error, so an unhandled exception can't leave the UI stuck in a loading state.
_worker.py itself is deliberately PySide-free (no Qt import), so its
exception-routing behavior is covered by a plain pytest test with no Qt
runtime involved.
qml/App/ is a local QML module (see qmldir) providing:
Theme.qml- a singleton holding every design token (colors, spacing, fonts) as properties. The look is a visual style (neon,console,soft,slate: neutrals and shape) times a resolved dark/light mode times an accent color, all three bound in fromThemeController. Every accent-family token is derived from the accent inTheme.qml.tests/test_theme_parity.pychecks that the style ids matchcore/appearance.pyand that each style's dark and light palettes define the same token keys.Ps*.qmlcomponents (PsButton,PsCard,PsDialog,PsSlider,PsSwitchRow,PsSelect,PsSectionHeader,PsNumberField,EnvField,PsChip,PsIconButton,PsRowButton,GlowBackground) - the shared component vocabulary every page is built from, all reading colors fromThemerather than hardcoding them.
qml/main.qml is the application window: it hosts the Bindings that drive
Theme from themeCtl, an ambient GlowBackground, and the navigation. The
nine pages are grouped by what they act on - Games (GamesPage), Tools
(ProtonPage, GamescopeBuilderPage), All games (EnvironmentPage,
MangoHudPage, ScopeBuddyPage), This PC (DisplayPage, SystemPage,
ControllersPage) - and SettingsPage sits in the header. The shared Ps*
controls play interface sounds through the sounds controller; a controller
named like a Button property (display) must be reached through a page
property inside button handlers, or the button's own property shadows it.
Splash.qml is a startup splash shown while the engine loads.
core/host.py provides host_run(), which prefixes flatpak-spawn --host
when running inside a Flatpak sandbox (detected via /.flatpak-info or
FLATPAK_ID) and is a transparent pass-through otherwise. Every core/
module that shells out to a host tool the sandbox doesn't provide -
nvidia-smi, powerprofilesctl, gamescope, protontricks, xrandr,
etc. - goes through it, so the same code path works identically native or
sandboxed.
Two manifests share one app ID (io.github.i4ctime.protonshift):
flatpak/io.github.i4ctime.protonshift.yml- local build. Allows network at build time andpip installs PySide6 andvdfdirectly. Fast path to a running package; not Flathub-eligible.flatpak/flathub/- offline Flathub manifest. No build-time network; PySide6 comes fromio.qt.PySide.BaseApp(Flathub's blessed way to ship PySide6 without duplicating Qt) rather than being pip-vendored, and only the pure-Pythonvdfdependency is vendored (flatpak/flathub/gen-vendor.shregenerates it).
See flatpak/README.md and flatpak/flathub/README.md for build commands and the Flathub submission checklist.
Pytest suite, all Qt-free by design so it runs without a display or a Qt runtime:
test_core_safety.py- path-traversal, command-injection, tolerant-decoding, and fail-closed-write regressions acrossshader_cache,scopebuddy,heroic_config,saves,vdf_config.test_input_devices_structs.py- pins the 64-bit kernel force-feedback ABI (struct ff_effectlayout,input_eventsize) thatinput_devices.pypacks by hand.test_theme_parity.py- cross-checksTheme.qml's palettes againsttheme_controller.py's palette ids, as plain text/regex (no Qt import).
CI (.github/workflows/ci.yml) runs ruff check, pyside6-qmllint over
protonshift/qml, and pytest, on Python 3.11 and 3.12, with
QT_QPA_PLATFORM=offscreen so nothing needs a real display.
- Work on a feature branch; CI runs lint + qmllint + tests on every push
and on PRs into
main(the only long-lived branch). - Open a PR into
main; merge once checks are green. - Tag the merge commit
vX.Y.Zand publish a GitHub Release from that tag. - Publishing the release triggers
.github/workflows/build-release.yml, which builds the Flatpak bundle (via the local, network-enabled manifest - the same command documented in flatpak/README.md) and uploadsio.github.i4ctime.protonshift-<tag>.flatpakas a release asset.
There is no AppImage, .deb, or .rpm build anymore - Flatpak is the only
distribution format.