3 Commits
Author SHA1 Message Date
oxpoe 1f3222bc96 feat: rilis v1.3.0 — popup tutup (tray/keluar), tampilan modern ringan, salin report, ingat ukuran jendela
- closeEvent: dialog 'Sembunyikan ke tray / Keluar / Batal' + 'Ingat pilihan ini'
  (tersimpan sebagai close_action; tombol sembunyikan dinonaktifkan bila tray tidak tersedia)
- QSS tema gelap modern: kartu membulat, tombol utama beraksen, kontrol bergaya; tanpa aset/efek berat
- tombol 'Salin report' (hex ke papan klip) untuk laporan bug
- ukuran & posisi jendela diingat (config geometry)
- perbaikan: dialog tutup tidak muncul saat keluar otomatis (--selftest), tunggu tugas latar saat keluar
- suite uji: 13 langkah, semua lolos di bundel beku
2026-10-13 09:02:50 +07:00
oxpoe ac23195996 build: sematkan info zsync ke AppImage (appimageupdatetool langsung bisa dipakai)
docs: catat bahwa rilis pasca-v1.2.3 membawa update information
2026-10-13 08:15:55 +07:00
oxpoe 7156458c6f feat(updater): verifikasi SHA-256 sebelum mengganti AppImage; CI menerbitkan .sha256 + .zsync
- do_update: unduh → verifikasi checksum rilis → baru ganti berkas (mismatch = dibatalkan)
- workflow: hasilkan GAMEMAX-LCD-x86_64.AppImage.sha256 dan .zsync (zsyncmake), lampirkan ketiganya
- README: bagian Updates menjelaskan checksum + zsync (delta update opsional)
- versi 1.2.3
2026-10-13 08:10:05 +07:00
4 changed files with 184 additions and 13 deletions

No files matched your search

+19 -2
View File
@@ -21,7 +21,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends \ sudo apt-get install -y --no-install-recommends \
libgl1 libegl1 libxkbcommon0 libdbus-1-3 libfuse2 fuse libgl1 libegl1 libxkbcommon0 libdbus-1-3 libfuse2 fuse zsync
- name: Set up uv - name: Set up uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v5
@@ -29,6 +29,7 @@ jobs:
- name: Build AppImage - name: Build AppImage
env: env:
APPIMAGE_EXTRACT_AND_RUN: "1" APPIMAGE_EXTRACT_AND_RUN: "1"
UPDATE_INFO_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/GAMEMAX-LCD-x86_64.AppImage.zsync
run: bash packaging/build_appimage.sh run: bash packaging/build_appimage.sh
- name: Smoke test the bundle (no display) - name: Smoke test the bundle (no display)
@@ -38,6 +39,15 @@ jobs:
run: | run: |
./dist/GAMEMAX-LCD-x86_64.AppImage --selftest ./dist/GAMEMAX-LCD-x86_64.AppImage --selftest
- name: Checksum and zsync metadata
run: |
cd dist
sha256sum GAMEMAX-LCD-x86_64.AppImage > GAMEMAX-LCD-x86_64.AppImage.sha256
zsyncmake -u "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/GAMEMAX-LCD-x86_64.AppImage" \
-o GAMEMAX-LCD-x86_64.AppImage.zsync GAMEMAX-LCD-x86_64.AppImage
cat GAMEMAX-LCD-x86_64.AppImage.sha256
ls -l
- name: Publish release - name: Publish release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@@ -58,5 +68,12 @@ jobs:
Device access still needs the udev rule once: Device access still needs the udev rule once:
`sudo cp packaging/70-gamemax.rules /etc/udev/rules.d/ && sudo udevadm control --reload` `sudo cp packaging/70-gamemax.rules /etc/udev/rules.d/ && sudo udevadm control --reload`
**Integrity:** verify the download with the published SHA-256, or use
`appimageupdatetool`/AppImageUpdate with the `.zsync` asset for delta updates.
The built-in updater (v1.2.3+) also verifies that checksum before replacing itself.
Independent project, not affiliated with the vendor — see DISCLAIMER.md. Independent project, not affiliated with the vendor — see DISCLAIMER.md.
files: dist/GAMEMAX-LCD-x86_64.AppImage files: |
dist/GAMEMAX-LCD-x86_64.AppImage
dist/GAMEMAX-LCD-x86_64.AppImage.sha256
dist/GAMEMAX-LCD-x86_64.AppImage.zsync
+9
View File
@@ -218,8 +218,17 @@ The app keeps itself current from GitHub Releases:
- When a newer release exists, a **Perbarui ke vX.Y.Z** button appears. One click downloads - When a newer release exists, a **Perbarui ke vX.Y.Z** button appears. One click downloads
the release asset, replaces the running AppImage atomically, refreshes the sender from the the release asset, replaces the running AppImage atomically, refreshes the sender from the
new bundle and restarts the service. Restart the app to use the new version. new bundle and restarts the service. Restart the app to use the new version.
- The download is verified against the release's published **SHA-256** before the AppImage is
replaced; a mismatch aborts the update. Older releases without a checksum are updated with a
warning in the log.
- Nothing is downloaded without that click, and no other files are touched. - Nothing is downloaded without that click, and no other files are touched.
Each release publishes three assets: the AppImage, its `.sha256`, and a `.zsync` file for
`appimageupdatetool` / AppImageUpdate (delta updates). The zsync file is optional — the
built-in updater does not need it — but the AppImage also carries the zsync URL as embedded
update information (releases built after v1.2.3), so `appimageupdatetool ./GAMEMAX-LCD.AppImage`
works directly.
If the check fails (offline, proxy), the message appears in the app log. `GAMEMAX_DEBUG=1` If the check fails (offline, proxy), the message appears in the app log. `GAMEMAX_DEBUG=1`
also mirrors the log to stdout for headless runs. also mirrors the log to stdout for headless runs.
+8 -2
View File
@@ -51,13 +51,19 @@ Terminal=false
Categories=System;Utility; Categories=System;Utility;
EOF EOF
# 4. package # 4. package (UPDATE_INFO_URL menyematkan info zsync agar appimageupdatetool bisa jalan)
UPDATE_INFO_ARG=""
if [ -n "${UPDATE_INFO_URL:-}" ]; then
UPDATE_INFO_ARG="-u zsync|${UPDATE_INFO_URL}"
echo "embedding update information: ${UPDATE_INFO_URL}"
fi
if [ ! -x "$BUILD_DIR/appimagetool" ]; then if [ ! -x "$BUILD_DIR/appimagetool" ]; then
curl -fsSL "$APPIMAGETOOL_URL" -o "$BUILD_DIR/appimagetool" curl -fsSL "$APPIMAGETOOL_URL" -o "$BUILD_DIR/appimagetool"
chmod +x "$BUILD_DIR/appimagetool" chmod +x "$BUILD_DIR/appimagetool"
fi fi
OUT="$OUT_DIR/GAMEMAX-LCD-x86_64.AppImage" OUT="$OUT_DIR/GAMEMAX-LCD-x86_64.AppImage"
ARCH=x86_64 "$BUILD_DIR/appimagetool" "$APPDIR" "$OUT" # shellcheck disable=SC2086
ARCH=x86_64 "$BUILD_DIR/appimagetool" $UPDATE_INFO_ARG "$APPDIR" "$OUT"
chmod +x "$OUT" chmod +x "$OUT"
echo "built: $OUT" echo "built: $OUT"
echo "verify: $OUT --selftest (or APPIMAGE_EXTRACT_AND_RUN=1 $OUT --selftest)" echo "verify: $OUT --selftest (or APPIMAGE_EXTRACT_AND_RUN=1 $OUT --selftest)"
+148 -9
View File
@@ -8,6 +8,7 @@ mengendalikannya dan memakai gamemax_report.py sebagai pengirim.
gamemax_gui.py --tray-only # tanpa jendela gamemax_gui.py --tray-only # tanpa jendela
gamemax_gui.py --selftest # uji muat tanpa tampilan (untuk build AppImage) gamemax_gui.py --selftest # uji muat tanpa tampilan (untuk build AppImage)
""" """
import hashlib
import json import json
import math import math
import os import os
@@ -26,11 +27,12 @@ from PySide6.QtGui import (QAction, QColor, QConicalGradient, QDesktopServices,
QIcon, QPainter, QPen, QPixmap) QIcon, QPainter, QPen, QPixmap)
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QCheckBox, QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QApplication, QCheckBox, QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QMenu, QPlainTextEdit, QProgressBar, QPushButton, QSystemTrayIcon, QVBoxLayout, QWidget, QMenu, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QSystemTrayIcon,
QVBoxLayout, QWidget,
) )
from PySide6.QtNetwork import QLocalServer, QLocalSocket from PySide6.QtNetwork import QLocalServer, QLocalSocket
APP_VERSION = "1.2.2" APP_VERSION = "1.3.0"
REPO = "oxpoe/Gamemax-linux-apps" REPO = "oxpoe/Gamemax-linux-apps"
RELEASES_API = f"https://api.github.com/repos/{REPO}/releases/latest" RELEASES_API = f"https://api.github.com/repos/{REPO}/releases/latest"
UPDATE_ASSET = "GAMEMAX-LCD-x86_64.AppImage" UPDATE_ASSET = "GAMEMAX-LCD-x86_64.AppImage"
@@ -64,6 +66,34 @@ WantedBy=default.target
RULE_TEMPLATE = ('SUBSYSTEM=="hidraw", ATTRS{idVendor}=="5131", ATTRS{idProduct}=="2007", ' RULE_TEMPLATE = ('SUBSYSTEM=="hidraw", ATTRS{idVendor}=="5131", ATTRS{idProduct}=="2007", '
'TAG+="uaccess"\n') 'TAG+="uaccess"\n')
APP_QSS = """
QWidget { background: #14121a; color: #ece6ff; font-size: 13px; }
QLabel#hint { color: #a99fc4; }
QGroupBox { border: 1px solid #2b2740; border-radius: 10px; margin-top: 14px;
padding: 12px 10px 8px 10px; font-weight: 600; }
QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 6px; color: #a99fc4; }
QPushButton { background: #242033; border: 1px solid #34304a; border-radius: 8px;
padding: 7px 13px; }
QPushButton:hover { background: #2c2740; }
QPushButton:pressed { background: #201c2e; }
QPushButton:disabled { color: #6c6486; border-color: #2a2740; }
QPushButton#primary { background: #def750; color: #14121a; border: none; font-weight: 700; }
QPushButton#primary:hover { background: #e9ff6a; }
QPushButton#primary:disabled { background: #4a5230; color: #2a2e20; }
QComboBox, QDoubleSpinBox { background: #1c1926; border: 1px solid #34304a;
border-radius: 8px; padding: 6px 8px; }
QComboBox QAbstractItemView { background: #1c1926; border: 1px solid #2b2740;
selection-background-color: #34304a; }
QPlainTextEdit { background: #1c1926; border: 1px solid #2b2740; border-radius: 10px;
padding: 6px; }
QProgressBar { background: #1c1926; border: 1px solid #2b2740; border-radius: 6px; }
QProgressBar::chunk { background: #def750; border-radius: 5px; }
QCheckBox { spacing: 8px; }
QCheckBox::indicator { width: 15px; height: 15px; border-radius: 4px;
border: 1px solid #6c6486; background: #1c1926; }
QCheckBox::indicator:checked { background: #def750; border-color: #def750; }
QMessageBox { background: #1b1826; }
"""
CRASH_LOG = os.path.join(os.path.dirname(CONFIG_PATH), "gui-crash.log") CRASH_LOG = os.path.join(os.path.dirname(CONFIG_PATH), "gui-crash.log")
ACCENT = QColor(222, 247, 80) # warna aksen GAMEMAX dari appConfig.ini ACCENT = QColor(222, 247, 80) # warna aksen GAMEMAX dari appConfig.ini
DIM = QColor(120, 128, 120) DIM = QColor(120, 128, 120)
@@ -96,6 +126,19 @@ def ensure_ca_bundle():
return return
def verify_sha256(path, sha_url):
"""True/False bila berkas checksum tersedia, None bila rilis tidak menyediakannya."""
try:
with urllib.request.urlopen(sha_url, timeout=10) as resp:
expected = resp.read().decode().split()[0].strip().lower()
except Exception: # noqa: BLE001
return None
if len(expected) != 64:
return None
actual = hashlib.sha256(open(path, "rb").read()).hexdigest()
return actual == expected
def latest_release(): def latest_release():
"""(tag, url aset) dari rilis terbaru GitHub — dipanggil dari thread pekerja.""" """(tag, url aset) dari rilis terbaru GitHub — dipanggil dari thread pekerja."""
ensure_ca_bundle() ensure_ca_bundle()
@@ -288,15 +331,20 @@ class Window(QWidget):
super().__init__() super().__init__()
self.tray = tray self.tray = tray
self.prev_stat = gr.stat_snapshot() self.prev_stat = gr.stat_snapshot()
self.setWindowTitle("GAMEMAX IceBurg LCD") self.setWindowTitle(f"GAMEMAX IceBurg LCD v{APP_VERSION}")
self.setWindowIcon(QIcon(icon_path())) self.setWindowIcon(QIcon(icon_path()))
self.setMinimumWidth(640) self.setMinimumWidth(640)
root = QVBoxLayout(self) root = QVBoxLayout(self)
self._extra = {k: v for k, v in gr.load_config(CONFIG_PATH).items()
if k in ("close_action", "geometry")}
geom = self._extra.get("geometry")
if isinstance(geom, list) and len(geom) == 4:
self.setGeometry(*[int(g) for g in geom])
hint = QLabel("Kontrol layar LCD AIO GAMEMAX. Pilih slot kipas/sumber suhu di " hint = QLabel("Kontrol layar LCD AIO GAMEMAX. Pilih slot kipas/sumber suhu di "
"<b>Pengaturan</b> lalu klik <b>Simpan &amp; terapkan</b>.") "<b>Pengaturan</b> lalu klik <b>Simpan &amp; terapkan</b>.")
hint.setObjectName("hint")
hint.setWordWrap(True) hint.setWordWrap(True)
hint.setStyleSheet("color:#c8ccc8;")
root.addWidget(hint) root.addWidget(hint)
# ---- banner mode layar (jelas: normal / kosong / berhenti) # ---- banner mode layar (jelas: normal / kosong / berhenti)
@@ -314,7 +362,7 @@ class Window(QWidget):
controls = QHBoxLayout() controls = QHBoxLayout()
self.toggle = QPushButton("Aktifkan layar") self.toggle = QPushButton("Aktifkan layar")
self.toggle.setMinimumHeight(38) self.toggle.setMinimumHeight(38)
self.toggle.setStyleSheet("font-weight: bold;") self.toggle.setObjectName("primary")
self.toggle.clicked.connect(lambda: self.start_task("mengubah status layanan", self.on_toggle)) self.toggle.clicked.connect(lambda: self.start_task("mengubah status layanan", self.on_toggle))
self.toggle.setToolTip("Mulai/hentikan pengiriman data sensor ke layar LCD") self.toggle.setToolTip("Mulai/hentikan pengiriman data sensor ke layar LCD")
controls.addWidget(self.toggle, 2) controls.addWidget(self.toggle, 2)
@@ -399,6 +447,10 @@ class Window(QWidget):
links.addWidget(donate) links.addWidget(donate)
links.addWidget(github) links.addWidget(github)
links.addStretch(1) links.addStretch(1)
copy_button = QPushButton("Salin report")
copy_button.setToolTip("Salin isi report 65 byte ke papan klip (untuk laporan bug)")
copy_button.clicked.connect(self.copy_report)
links.addWidget(copy_button)
self.check_button = QPushButton("Periksa pembaruan") self.check_button = QPushButton("Periksa pembaruan")
self.check_button.setToolTip("Bandingkan dengan rilis terbaru di GitHub") self.check_button.setToolTip("Bandingkan dengan rilis terbaru di GitHub")
self.check_button.clicked.connect(lambda: self.start_task("memeriksa pembaruan", self.do_check_update)) self.check_button.clicked.connect(lambda: self.start_task("memeriksa pembaruan", self.do_check_update))
@@ -433,7 +485,8 @@ class Window(QWidget):
self.poll = QTimer(self, timeout=self.refresh, interval=1000) self.poll = QTimer(self, timeout=self.refresh, interval=1000)
self.poll.start() self.poll.start()
self.refresh() self.refresh()
if installed_appimage(): # hanya pada versi terpasang testing = any(f in sys.argv for f in ("--selftest", "--selftest-actions"))
if installed_appimage() and not testing: # hanya pada versi terpasang
QTimer.singleShot(2500, lambda: self.start_task("memeriksa pembaruan", QTimer.singleShot(2500, lambda: self.start_task("memeriksa pembaruan",
self.do_check_update)) self.do_check_update))
if gr.load_config(CONFIG_PATH).get("blank"): if gr.load_config(CONFIG_PATH).get("blank"):
@@ -469,6 +522,7 @@ class Window(QWidget):
def config(self): def config(self):
return { return {
**self._extra,
"fan_channel": self.fan.currentData() or None, "fan_channel": self.fan.currentData() or None,
"temp_channel": self.temp.currentData() or None, "temp_channel": self.temp.currentData() or None,
"unit": "c", # panel hanya punya label °C "unit": "c", # panel hanya punya label °C
@@ -478,6 +532,67 @@ class Window(QWidget):
"blank": bool(self.blank.isChecked()), "blank": bool(self.blank.isChecked()),
} }
def copy_report(self):
QApplication.clipboard().setText(getattr(self, "_report_hex", ""))
self.log("isi report disalin ke papan klip")
def save_geometry(self):
g = self.geometry()
self._extra["geometry"] = [g.x(), g.y(), g.width(), g.height()]
try:
self.write_config(self.config())
except OSError:
pass
def closeEvent(self, event): # noqa: N802 (Qt)
if getattr(self, "_quitting", False):
self.save_geometry()
event.accept()
return
action = self._extra.get("close_action", "ask")
if action == "tray" and self.tray.available():
self.save_geometry()
self.hide()
event.ignore()
return
if action == "quit":
self.save_geometry()
self._quitting = True
event.accept()
QApplication.quit()
return
box = QMessageBox(self)
box.setWindowTitle("Tutup GAMEMAX LCD")
box.setText("Sembunyikan ke tray atau keluar sepenuhnya?")
box.setInformativeText("Selama tersembunyi di tray, data sensor tetap dikirim ke layar.")
remember = QCheckBox("Ingat pilihan ini")
box.setCheckBox(remember)
hide_btn = box.addButton("Sembunyikan ke tray", QMessageBox.AcceptRole)
quit_btn = box.addButton("Keluar", QMessageBox.DestructiveRole)
box.addButton("Batal", QMessageBox.RejectRole)
if not self.tray.available():
hide_btn.setEnabled(False)
box.setInformativeText("Tray tidak tersedia di desktop ini — menutup jendela akan "
"menghentikan aplikasi.")
box.exec()
clicked = box.clickedButton()
if clicked is hide_btn:
if remember.isChecked():
self._extra["close_action"] = "tray"
self.save_geometry()
self.hide()
event.ignore()
elif clicked is quit_btn:
if remember.isChecked():
self._extra["close_action"] = "quit"
self.save_geometry()
self._quitting = True
event.accept()
QApplication.quit()
else:
event.ignore()
# -------------------------------------------------------------- pembaruan # -------------------------------------------------------------- pembaruan
def do_check_update(self): def do_check_update(self):
tag, url = latest_release() tag, url = latest_release()
@@ -503,6 +618,10 @@ class Window(QWidget):
ensure_ca_bundle() ensure_ca_bundle()
tmp = target + ".new" tmp = target + ".new"
urllib.request.urlretrieve(url, tmp) urllib.request.urlretrieve(url, tmp)
checked = verify_sha256(tmp, url + ".sha256")
if checked is False:
os.unlink(tmp)
raise RuntimeError("checksum SHA-256 tidak cocok — unduhan dibatalkan")
os.chmod(tmp, 0o755) os.chmod(tmp, 0o755)
os.replace(tmp, target) # ganti berkas secara atomik os.replace(tmp, target) # ganti berkas secara atomik
# sender di layanan juga diambil dari bundel baru # sender di layanan juga diambil dari bundel baru
@@ -514,7 +633,11 @@ class Window(QWidget):
os.makedirs(INSTALL_DIR, exist_ok=True) os.makedirs(INSTALL_DIR, exist_ok=True)
shutil.copyfile(sender, os.path.join(INSTALL_DIR, "gamemax_report.py")) shutil.copyfile(sender, os.path.join(INSTALL_DIR, "gamemax_report.py"))
systemctl("restart", SERVICE) systemctl("restart", SERVICE)
return f"diperbarui ke {tag} — tutup jendela ini lalu buka lagi untuk memakai versi baru" note = "" if checked is None else " (checksum SHA-256 terverifikasi)"
if checked is None:
note = " (rilis ini tidak menyertakan checksum)"
return (f"diperbarui ke {tag}{note} — tutup jendela ini lalu buka lagi "
"untuk memakai versi baru")
# -------------------------------------------------------------- tugas latar # -------------------------------------------------------------- tugas latar
def start_task(self, label, fn): def start_task(self, label, fn):
@@ -795,7 +918,7 @@ class Tray:
github_action = QAction("GitHub", menu) github_action = QAction("GitHub", menu)
github_action.triggered.connect(lambda: open_link(GITHUB_URL, self.window)) github_action.triggered.connect(lambda: open_link(GITHUB_URL, self.window))
quit_action = QAction("Keluar", menu) quit_action = QAction("Keluar", menu)
quit_action.triggered.connect(app.quit) quit_action.triggered.connect(self.quit_app)
menu.addAction(show) menu.addAction(show)
menu.addAction(toggle) menu.addAction(toggle)
menu.addAction(self.blank_action) menu.addAction(self.blank_action)
@@ -811,6 +934,13 @@ class Tray:
self.window = Window(self) self.window = Window(self)
self.icon.show() self.icon.show()
def available(self):
return QSystemTrayIcon.isSystemTrayAvailable()
def quit_app(self):
self.window._quitting = True
self.app.quit()
def sync(self, active): def sync(self, active):
blank = bool(gr.load_config(CONFIG_PATH).get("blank")) blank = bool(gr.load_config(CONFIG_PATH).get("blank"))
state = ("layar kosong (nilai nol)" if blank and active state = ("layar kosong (nilai nol)" if blank and active
@@ -847,6 +977,7 @@ def main():
def _main(): def _main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("GAMEMAX LCD") app.setApplicationName("GAMEMAX LCD")
app.setStyleSheet(APP_QSS)
app.setQuitOnLastWindowClosed(False) app.setQuitOnLastWindowClosed(False)
# hanya satu instance: yang kedua memunculkan jendela yang sudah ada lalu keluar # hanya satu instance: yang kedua memunculkan jendela yang sudah ada lalu keluar
@@ -868,7 +999,14 @@ def _main():
tray = Tray(app) tray = Tray(app)
server.newConnection.connect(lambda: tray.show_window()) server.newConnection.connect(lambda: tray.show_window())
app.aboutToQuit.connect(lambda: (server.close(), QLocalServer.removeServer(SOCKET_NAME))) def on_quit():
task = getattr(tray.window, "_task", None)
if task is not None and task.isRunning():
task.wait(3000)
server.close()
QLocalServer.removeServer(SOCKET_NAME)
app.aboutToQuit.connect(on_quit)
# Jendela selalu dibuka saat diluncurkan; tray tetap ada kalau didukung desktop. # Jendela selalu dibuka saat diluncurkan; tray tetap ada kalau didukung desktop.
tray.show_window() tray.show_window()
if "--tray-only" in sys.argv: if "--tray-only" in sys.argv:
@@ -928,6 +1066,7 @@ def _main():
print("log:", " | ".join(w.logview.toPlainText().splitlines()[-6:])) print("log:", " | ".join(w.logview.toPlainText().splitlines()[-6:]))
return 0 return 0
if "--selftest" in sys.argv: if "--selftest" in sys.argv:
tray.window._quitting = True # keluar otomatis: lewati dialog tutup
QTimer.singleShot(1200, app.quit) QTimer.singleShot(1200, app.quit)
rc = app.exec() rc = app.exec()
if "--selftest" in sys.argv: if "--selftest" in sys.argv: