Skip to content

Commit fdc4f6f

Browse files
committed
Add portable build support (PyInstaller)
1 parent d8b214b commit fdc4f6f

4 files changed

Lines changed: 332 additions & 6 deletions

File tree

LogiControl.spec

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
# -*- mode: python ; coding: utf-8 -*-
2+
"""
3+
PyInstaller spec file for LogiControl
4+
Produces a single-directory portable build in dist/LogiControl/
5+
Run: pyinstaller LogiControl.spec
6+
"""
7+
8+
import os
9+
import sys
10+
import shutil
11+
import PySide6
12+
13+
block_cipher = None
14+
ROOT = os.path.abspath(".")
15+
PYSIDE6_DIR = os.path.dirname(PySide6.__file__)
16+
17+
a = Analysis(
18+
["main_qml.py"],
19+
pathex=[ROOT],
20+
binaries=[],
21+
datas=[
22+
# QML UI files
23+
(os.path.join(ROOT, "ui", "qml"), os.path.join("ui", "qml")),
24+
# Image assets
25+
(os.path.join(ROOT, "images"), "images"),
26+
],
27+
hiddenimports=[
28+
# conditional / lazy imports PyInstaller may miss
29+
"hid",
30+
"ctypes.wintypes",
31+
# PySide6 QML runtime
32+
"PySide6.QtQuick",
33+
"PySide6.QtQuickControls2",
34+
"PySide6.QtQml",
35+
"PySide6.QtNetwork",
36+
"PySide6.QtOpenGL",
37+
],
38+
hookspath=[],
39+
hooksconfig={},
40+
runtime_hooks=[],
41+
excludes=[
42+
# ── Aggressively trim unneeded PySide6 modules ──
43+
"PySide6.QtWebEngine",
44+
"PySide6.QtWebEngineCore",
45+
"PySide6.QtWebEngineWidgets",
46+
"PySide6.QtWebChannel",
47+
"PySide6.QtWebSockets",
48+
"PySide6.Qt3DCore",
49+
"PySide6.Qt3DRender",
50+
"PySide6.Qt3DInput",
51+
"PySide6.Qt3DLogic",
52+
"PySide6.Qt3DAnimation",
53+
"PySide6.Qt3DExtras",
54+
"PySide6.QtMultimedia",
55+
"PySide6.QtMultimediaWidgets",
56+
"PySide6.QtBluetooth",
57+
"PySide6.QtNfc",
58+
"PySide6.QtPositioning",
59+
"PySide6.QtLocation",
60+
"PySide6.QtSensors",
61+
"PySide6.QtSerialPort",
62+
"PySide6.QtSerialBus",
63+
"PySide6.QtTest",
64+
"PySide6.QtPdf",
65+
"PySide6.QtPdfWidgets",
66+
"PySide6.QtCharts",
67+
"PySide6.QtDataVisualization",
68+
"PySide6.QtRemoteObjects",
69+
"PySide6.QtScxml",
70+
"PySide6.QtSql",
71+
"PySide6.QtSvg",
72+
"PySide6.QtSvgWidgets",
73+
"PySide6.QtTextToSpeech",
74+
"PySide6.QtQuick3D",
75+
"PySide6.QtVirtualKeyboard",
76+
"PySide6.QtGraphs",
77+
"PySide6.Qt5Compat",
78+
# ── Other unused stdlib modules ──
79+
"unittest",
80+
"xmlrpc",
81+
"pydoc",
82+
"doctest",
83+
"tkinter",
84+
],
85+
noarchive=False,
86+
)
87+
88+
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
89+
90+
# ── Filter out massive Qt DLLs and data we don't need ──────────────────
91+
# The PySide6 hooks copy EVERYTHING (WebEngine=193MB, 3D, Charts, etc.).
92+
# We only need: Core, Gui, Widgets, Qml, Quick, QuickControls2 (Material),
93+
# OpenGL, Network, ShaderTools, and a few essentials.
94+
_qt_keep = {
95+
# Core Qt
96+
"Qt6Core", "Qt6Gui", "Qt6Widgets", "Qt6Network", "Qt6OpenGL",
97+
# QML / Quick
98+
"Qt6Qml", "Qt6QmlCore", "Qt6QmlMeta", "Qt6QmlModels",
99+
"Qt6QmlNetwork", "Qt6QmlWorkerScript",
100+
"Qt6Quick", "Qt6QuickControls2", "Qt6QuickControls2Impl",
101+
"Qt6QuickControls2Basic", "Qt6QuickControls2BasicStyleImpl",
102+
"Qt6QuickControls2Material", "Qt6QuickControls2MaterialStyleImpl",
103+
"Qt6QuickTemplates2", "Qt6QuickLayouts", "Qt6QuickEffects",
104+
"Qt6QuickShapes",
105+
# Rendering
106+
"Qt6ShaderTools",
107+
# PySide6 runtime
108+
"pyside6.abi3", "pyside6qml.abi3", "shiboken6.abi3",
109+
# VC runtime
110+
"MSVCP140", "MSVCP140_1", "MSVCP140_2",
111+
"VCRUNTIME140", "VCRUNTIME140_1",
112+
}
113+
114+
def _should_keep(name):
115+
"""Return True if this binary/data entry should be kept."""
116+
# Always keep non-PySide6 files
117+
if "PySide6" not in name and "pyside6" not in name.lower():
118+
return True
119+
# Check the filename (last component)
120+
base = os.path.basename(name)
121+
stem = os.path.splitext(base)[0]
122+
# Keep if it's in our whitelist
123+
if stem in _qt_keep:
124+
return True
125+
# Keep all .pyd files (Python extensions — small and needed)
126+
if base.endswith(".pyd"):
127+
return True
128+
# Keep plugin dirs we need (platforms, imageformats, styles, iconengines)
129+
for keep in ("platforms", "imageformats", "styles", "iconengines",
130+
"platforminputcontexts"):
131+
if keep in name:
132+
return True
133+
# Keep QML dirs we need
134+
for keep_qml in ("QtCore", "QtQml", "QtQuick", "QtNetwork"):
135+
pat = os.path.join("qml", keep_qml)
136+
if pat in name.replace("/", os.sep):
137+
return True
138+
# Drop everything else (WebEngine, 3D, Charts, Multimedia, etc.)
139+
return False
140+
141+
a.binaries = [b for b in a.binaries if _should_keep(b[0])]
142+
a.datas = [d for d in a.datas if _should_keep(d[0])]
143+
144+
exe = EXE(
145+
pyz,
146+
a.scripts,
147+
[], # not one-file (faster startup, easier debugging)
148+
exclude_binaries=True,
149+
name="LogiControl",
150+
debug=False,
151+
bootloader_ignore_signals=False,
152+
strip=False,
153+
upx=False, # UPX OFF — decompression at startup is very slow
154+
console=False, # windowed app (no terminal)
155+
icon=os.path.join(ROOT, "images", "logo.ico"),
156+
uac_admin=False, # does NOT require admin
157+
)
158+
159+
coll = COLLECT(
160+
exe,
161+
a.binaries,
162+
a.zipfiles,
163+
a.datas,
164+
strip=False,
165+
upx=False, # UPX OFF — faster cold start
166+
upx_exclude=[],
167+
name="LogiControl",
168+
)
169+
170+
# ── Post-build cleanup: remove Qt QML/plugin dirs we don't need ──────────
171+
# PyInstaller's hooks copy the entire PySide6 QML tree; we only need
172+
# QtQuick/Controls + Material, QtQml, QtQuick/Layouts, QtQuick/Templates,
173+
# QtQuick/Window. Everything else is dead weight that slows startup.
174+
_dist = os.path.join("dist", "LogiControl", "_internal", "PySide6")
175+
176+
# QML dirs to KEEP (everything else under qml/ is deleted)
177+
_keep_qml = {
178+
"QtCore", "QtQml", "QtQuick", "QtNetwork",
179+
}
180+
181+
# Under QtQuick, keep only what the app uses
182+
_keep_qtquick = {
183+
"Controls", "Layouts", "Templates", "Window",
184+
}
185+
186+
# Plugin dirs to KEEP
187+
_keep_plugins = {
188+
"iconengines", "imageformats", "platforms",
189+
"platforminputcontexts", "styles",
190+
}
191+
192+
def _cleanup():
193+
qml_root = os.path.join(_dist, "qml")
194+
if os.path.isdir(qml_root):
195+
for d in os.listdir(qml_root):
196+
if d not in _keep_qml:
197+
p = os.path.join(qml_root, d)
198+
if os.path.isdir(p):
199+
shutil.rmtree(p, ignore_errors=True)
200+
print(f" [cleanup] removed qml/{d}")
201+
202+
# Trim inside QtQuick
203+
qtquick = os.path.join(qml_root, "QtQuick")
204+
if os.path.isdir(qtquick):
205+
for d in os.listdir(qtquick):
206+
if d not in _keep_qtquick:
207+
p = os.path.join(qtquick, d)
208+
if os.path.isdir(p):
209+
shutil.rmtree(p, ignore_errors=True)
210+
print(f" [cleanup] removed qml/QtQuick/{d}")
211+
212+
plugins_root = os.path.join(_dist, "plugins")
213+
if os.path.isdir(plugins_root):
214+
for d in os.listdir(plugins_root):
215+
if d not in _keep_plugins:
216+
p = os.path.join(plugins_root, d)
217+
if os.path.isdir(p):
218+
shutil.rmtree(p, ignore_errors=True)
219+
print(f" [cleanup] removed plugins/{d}")
220+
221+
# Remove translations (not needed)
222+
trans = os.path.join(_dist, "translations")
223+
if os.path.isdir(trans):
224+
shutil.rmtree(trans, ignore_errors=True)
225+
print(" [cleanup] removed translations/")
226+
227+
print("[LogiControl] Post-build cleanup...")
228+
_cleanup()
229+
print("[LogiControl] Cleanup done.")

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,19 @@ _The UI shows an interactive diagram of the MX Master 3S. Click any button's hot
6666

6767
---
6868

69-
## Installation
69+
## Quick Start (Portable — no install needed)
70+
71+
1. **Download** the latest `LogiControl.zip` from the [Releases](https://github.com/YOUR_USERNAME/logi-control/releases) page
72+
2. **Extract** the zip to any folder
73+
3. **Run** `LogiControl.exe`
74+
75+
That's it — no Python, no dependencies, no installer. The app stores its config in `%APPDATA%\LogiControl`.
76+
77+
> **Note:** Windows SmartScreen may show a warning the first time. Click **More info → Run anyway**.
78+
79+
---
80+
81+
## Installation (from source)
7082

7183
### Prerequisites
7284

@@ -129,6 +141,23 @@ $s.IconLocation = "C:\path\to\logi-control\images\logo.ico, 0"
129141
$s.Save()
130142
```
131143

144+
### Building the Portable App
145+
146+
To produce a standalone `LogiControl.exe` that anyone can download and run without Python:
147+
148+
```bash
149+
# 1. Install PyInstaller (inside your venv)
150+
pip install pyinstaller
151+
152+
# 2. Build using the included spec file
153+
pyinstaller LogiControl.spec --noconfirm
154+
155+
# — or simply run the build script —
156+
build.bat
157+
```
158+
159+
The output is in `dist\LogiControl\`. Zip that entire folder and distribute it.
160+
132161
---
133162

134163
## How It Works

build.bat

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
@echo off
2+
:: ──────────────────────────────────────────────────────────────
3+
:: build.bat — Build a portable LogiControl distribution
4+
::
5+
:: Produces: dist\LogiControl\LogiControl.exe (+ supporting files)
6+
:: Zip that folder and distribute — no Python install required.
7+
:: ──────────────────────────────────────────────────────────────
8+
title LogiControl — Build
9+
cd /d "%~dp0"
10+
11+
echo.
12+
echo === LogiControl Portable Build ===
13+
echo.
14+
15+
:: ── 1. Activate venv if present ──────────────────────────────
16+
if exist ".venv\Scripts\activate.bat" (
17+
call ".venv\Scripts\activate.bat"
18+
echo [*] Virtual-env activated
19+
) else (
20+
echo [!] No .venv found — using system Python
21+
)
22+
23+
:: ── 2. Ensure PyInstaller is installed ───────────────────────
24+
pip show pyinstaller >nul 2>&1
25+
if %errorlevel% neq 0 (
26+
echo [*] Installing PyInstaller...
27+
pip install pyinstaller
28+
)
29+
30+
:: ── 3. Clean previous build ──────────────────────────────────
31+
if exist "dist\LogiControl" (
32+
echo [*] Removing previous dist\LogiControl...
33+
rmdir /s /q "dist\LogiControl"
34+
)
35+
if exist "build\LogiControl" (
36+
rmdir /s /q "build\LogiControl"
37+
)
38+
39+
:: ── 4. Run PyInstaller ───────────────────────────────────────
40+
echo [*] Building with PyInstaller...
41+
pyinstaller LogiControl.spec --noconfirm
42+
43+
if %errorlevel% neq 0 (
44+
echo.
45+
echo [ERROR] Build failed — see messages above.
46+
pause
47+
exit /b 1
48+
)
49+
50+
:: ── 5. Copy default config if missing ────────────────────────
51+
:: (not needed — config is auto-created at first run in %APPDATA%)
52+
53+
echo.
54+
echo === Build complete! ===
55+
echo Output: dist\LogiControl\LogiControl.exe
56+
echo.
57+
echo To distribute: zip the dist\LogiControl folder.
58+
echo.
59+
pause

main_qml.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99
import sys
1010
import os
1111

12-
# Ensure project root on path
13-
ROOT = os.path.dirname(os.path.abspath(__file__))
12+
# Ensure project root on path — works for both normal Python and PyInstaller
13+
if getattr(sys, "frozen", False):
14+
# PyInstaller 6.x: data files are in _internal/ next to the exe
15+
ROOT = os.path.join(os.path.dirname(sys.executable), "_internal")
16+
else:
17+
ROOT = os.path.dirname(os.path.abspath(__file__))
1418
sys.path.insert(0, ROOT)
1519

1620
# Set Material theme before any Qt imports
@@ -46,10 +50,8 @@ def main():
4650
app.setOrganizationName("LogiControl")
4751
app.setWindowIcon(_app_icon())
4852

49-
# ── Engine ─────────────────────────────────────────────────
53+
# ── Engine (created but started AFTER UI is visible) ───────
5054
engine = Engine()
51-
engine.start()
52-
print("[LogiControl] Engine started — remapping is active")
5355

5456
# ── QML Backend ────────────────────────────────────────────
5557
backend = Backend(engine)
@@ -69,6 +71,13 @@ def main():
6971

7072
root_window = qml_engine.rootObjects()[0]
7173

74+
# ── Start engine AFTER window is ready (deferred) ──────────
75+
from PySide6.QtCore import QTimer
76+
QTimer.singleShot(0, lambda: (
77+
engine.start(),
78+
print("[LogiControl] Engine started — remapping is active"),
79+
))
80+
7281
# ── System Tray ────────────────────────────────────────────
7382
tray = QSystemTrayIcon(_app_icon(), app)
7483
tray.setToolTip("LogiControl — MX Master 3S")

0 commit comments

Comments
 (0)