Add custom icon and tray-minimize behavior (like VersaGUI's TrayApp)

Minimizing or closing hides the window instead of leaving a taskbar
entry; only "Beenden" in the tray right-click menu really exits. Tray
callbacks route through a queue instead of touching Tk directly from
pystray's thread (same pattern as the earlier serial-thread fix).

Also fixes the build script: PyInstaller failed on the network share
while copying Tcl/Tk tzdata (path-length issue), so building now
happens in a local temp copy instead of directly on Z:.

Adds an info button that explains the MCP server's available tools.
This commit is contained in:
cjjohn 2026-08-05 08:30:50 +02:00
parent e456af7c19
commit 4ac29a3e5c
6 changed files with 208 additions and 25 deletions

View file

@ -116,30 +116,63 @@ zuverlässig zur echten Python313-Installation auf.
## Gepackte .exe (PyInstaller) — kein Terminal-Flash, kein Python-Setup nötig ## Gepackte .exe (PyInstaller) — kein Terminal-Flash, kein Python-Setup nötig
`build_and_deploy.ps1` baut `desktop_viewer.py` mit PyInstaller `build_and_deploy.ps1` kopiert den Quellcode zuerst nach `%TEMP%`, baut dort
(`--windowed --onedir`) und kopiert das Ergebnis lokal nach mit PyInstaller (`--windowed --onedir --icon icon.ico`), und kopiert das
`%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. Desktop-Verknüpfung Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`.
"VersaPad Viewer (Fenster)" zeigt dorthin. Desktop-Verknüpfung "VersaPad Viewer (Fenster)" zeigt dorthin (inkl. eigenem
Icon, `IconLocation` zeigt auf die `.exe` selbst statt auf `pythonw.exe`).
**`--onedir`, nicht `--onefile`** — Single-File-PyInstaller-Bundles lösen **`--onedir`, nicht `--onefile`** — Single-File-PyInstaller-Bundles lösen
öfter AV-Fehlalarme aus (sehen strukturell wie ein Packer aus); `--onedir` öfter AV-Fehlalarme aus (sehen strukturell wie ein Packer aus); `--onedir`
(Ordner mit `.exe` + `_internal`) ist unauffälliger. Trotzdem unsigniert, (Ordner mit `.exe` + `_internal`) ist unauffälliger. Trotzdem unsigniert,
also grundsätzlich nie über Smart App Control/AV in Stein gemeißelt sicher. also grundsätzlich nie über Smart App Control/AV in Stein gemeißelt sicher.
**Kritischer Stolperstein:** Die gebaute `.exe` läuft NICHT vom Netzlaufwerk **Zwei Netzlaufwerk-Stolpersteine, deshalb baut UND deployt alles lokal:**
aus (`Z:\Git\...`, SMB-Share auf cj-ki) — Windows blockiert das Nachladen 1. Laufzeit: die gebaute `.exe` läuft NICHT vom Netzlaufwerk aus (`Z:\Git\...`,
der `_internal`-DLLs von einem Netzwerkpfad, der Prozess startet dann SMB-Share auf cj-ki) — Windows blockiert das Nachladen der
einfach nie, ganz ohne Fehlermeldung/Crash-Log/AV-Benachrichtigung (sah `_internal`-DLLs von einem Netzwerkpfad, der Prozess startet dann einfach
zuerst wie ein AV-Block aus, war aber keiner — sauber verifiziert indem nie, ganz ohne Fehlermeldung/Crash-Log/AV-Benachrichtigung (sah zuerst wie
dieselbe `.exe` 1:1 lokal kopiert sofort lief). Bauen darf auf Z: passieren, ein AV-Block aus, war aber keiner — verifiziert indem dieselbe `.exe` 1:1
das fertige Bundle muss vor dem Start nach C: kopiert werden — genau das lokal kopiert sofort lief).
macht `build_and_deploy.ps1`. 2. Buildzeit: PyInstaller scheitert beim Kopieren der Tcl/Tk-`tzdata`
(viele tief verschachtelte, lang benannte Zeitzonen-Ordner) auf dem
SMB-Share mit `FileNotFoundError: [WinError 3]` — Pfadlängen-Problem,
kombiniert mit dem eh schon langen Projektpfad (`Z:\Git\Versa Board\
versapad-viewer\dist\...`).
Deshalb kopiert `build_and_deploy.ps1` den Quellcode zuerst nach `%TEMP%`
und baut NUR dort — Z: wird nur zum Lesen der `.py`-Dateien angefasst.
Bei erneutem Bauen: `.\build_and_deploy.ps1` in PowerShell ausführen Bei erneutem Bauen: `.\build_and_deploy.ps1` in PowerShell ausführen
(lokal, nicht über die sandboxed Bash-Tool-Umgebung — die hat eine eigene (lokal, nicht über die sandboxed Bash-Tool-Umgebung — die hat eine eigene
Dateisystem-Sicht, die nicht zuverlässig mit dem echten Windows-Dateisystem Dateisystem-Sicht, die nicht zuverlässig mit dem echten Windows-Dateisystem
übereinstimmt; Kopieroperationen für Dinge, die der User/andere Prozesse übereinstimmt; siehe Sessionlog: ein `cp -r` über Bash landete an einem Ort,
sehen sollen, gehören in PowerShell, nicht in Bash). den PowerShell/der reale Explorer nicht sah. Kopieroperationen für Dinge,
die der User/andere Prozesse sehen sollen, gehören in PowerShell, nicht in
Bash).
## Icon + Tray (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI)
- `icon.png`/`icon.ico` — generiert via Pillow (dunkles abgerundetes Quadrat,
blauer Block, 2x2-Punktraster; flaches Design, keine Farbverläufe/
Anti-Aliasing-Details, siehe Speicher-Notiz zu Tkinter-Flat-Design).
`.ico` fürs Fenster-/Taskleisten-/Exe-Icon, `.png` fürs Tray-Icon (pystray
braucht ein PIL-Image, kein Multi-Res-`.ico`).
- `resource_path()` in `desktop_viewer.py` findet Assets sowohl im
Quellordner als auch im PyInstaller-Bundle (`sys._MEIPASS`).
- Minimieren (`<Unmap>` mit `state()=='iconic'`) ruft `withdraw()` statt
normal zu minimieren → kein Taskleisten-Eintrag, nur noch Tray-Icon.
Schließen (X) macht dasselbe (`_hide_to_tray`, nicht `_quit`) — echtes
Beenden nur über "Beenden" im Tray-Rechtsklickmenü (mirrort VersaGUIs
`TrayApp`-Menü: Öffnen/Beenden). Tray-Icon läuft über `pystray.Icon.
run_detached()` in eigenem Thread — Klicks im Tray-Menü legen nur ein
Ereignis in `self._tray_queue`, das der Main-Thread per `after()`-Polling
abholt (dieselbe Queue-statt-Direktzugriff-Regel wie beim Serial-Thread,
siehe oben — Tk-Aufrufe aus einem Fremdthread haben schon mal einen
stillen Absturz verursacht).
- Neue Abhängigkeiten: `pystray`, `pillow` (zusätzlich zu `pyserial`).
- Info-Button (ⓘ oben rechts im Header) öffnet einen Dialog mit der
MCP-Server-Doku (`MCP_INFO_TEXT`-Konstante) — rein informativ, keine Logik.
## Dateien/Pfade (hardcoded, hal9001-spezifisch) ## Dateien/Pfade (hardcoded, hal9001-spezifisch)

View file

@ -5,7 +5,7 @@ a = Analysis(
['desktop_viewer.py'], ['desktop_viewer.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[('icon.png', '.'), ('icon.ico', '.')],
hiddenimports=[], hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
@ -32,6 +32,7 @@ exe = EXE(
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon=['icon.ico'],
) )
coll = COLLECT( coll = COLLECT(
exe, exe,

View file

@ -1,19 +1,38 @@
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
# nach lokal (C:\Users\<user>\AppData\Local\VersaPadViewer). # nach lokal (C:\Users\<user>\AppData\Local\VersaPadViewer).
# #
# WICHTIG: Muss lokal laufen, NICHT direkt vom Netzlaufwerk (Z:\Git\...) -- # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf
# Windows blockiert das Nachladen der PyInstaller-_internal-DLLs von einem # dem Netzlaufwerk (Z:\Git\...):
# Netzwerkpfad (SMB-Share) ohne jede Fehlermeldung, der Prozess startet # - Laufzeit: Windows blockiert das Nachladen der PyInstaller-_internal-
# einfach nie. Deshalb bauen wir hier (Quelle darf auf Z: liegen) und # DLLs von einem Netzwerkpfad (SMB-Share) ohne jede Fehlermeldung, der
# kopieren das fertige Bundle danach nach C:. # Prozess startet einfach nie.
# - Buildzeit: PyInstaller scheitert beim Kopieren von Tcl/Tk-tzdata (viele
# tief verschachtelte, lang benannte Zeitzonen-Ordner) mit
# "FileNotFoundError: [WinError 3]" -- Pfadlaengen-Problem auf dem
# SMB-Share, kombiniert mit dem eh schon langen Projektpfad.
#
# Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach
# das fertige Bundle nach %LOCALAPPDATA% kopieren. Z: wird nur zum Lesen der
# Quelldateien angefasst.
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$projectDir = $PSScriptRoot $projectDir = $PSScriptRoot
$buildSrc = "$env:TEMP\versapad_build_src"
$localDir = "$env:LOCALAPPDATA\VersaPadViewer" $localDir = "$env:LOCALAPPDATA\VersaPadViewer"
Push-Location $projectDir if (Test-Path $buildSrc) {
Remove-Item $buildSrc -Recurse -Force
}
New-Item -ItemType Directory -Path $buildSrc | Out-Null
Copy-Item "$projectDir\*.py" -Destination $buildSrc
Copy-Item "$projectDir\icon.ico" -Destination $buildSrc
Copy-Item "$projectDir\icon.png" -Destination $buildSrc
Push-Location $buildSrc
try { try {
py -m PyInstaller --windowed --onedir --name VersaPadViewer desktop_viewer.py --noconfirm py -m PyInstaller --windowed --onedir --name VersaPadViewer `
--icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." `
desktop_viewer.py --noconfirm
} finally { } finally {
Pop-Location Pop-Location
} }
@ -21,6 +40,7 @@ try {
if (Test-Path $localDir) { if (Test-Path $localDir) {
Remove-Item $localDir -Recurse -Force Remove-Item $localDir -Recurse -Force
} }
Copy-Item "$projectDir\dist\VersaPadViewer" -Destination $localDir -Recurse Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse
Remove-Item $buildSrc -Recurse -Force
Write-Host "Fertig: $localDir\VersaPadViewer.exe" Write-Host "Fertig: $localDir\VersaPadViewer.exe"

View file

@ -21,17 +21,28 @@ Start: python desktop_viewer.py
""" """
import os import os
import queue import queue
import sys
import threading import threading
import time import time
import tkinter as tk import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog from tkinter import filedialog, messagebox, simpledialog
import pystray
from PIL import Image
import action_dialog import action_dialog
import versapad_combined as vcomb import versapad_combined as vcomb
import versapad_data as vp import versapad_data as vp
import versapad_protocol as vproto import versapad_protocol as vproto
import versapad_serial as vs import versapad_serial as vs
def resource_path(name):
"""Findet Assets (z.B. icon.png) sowohl im Quellordner als auch im
PyInstaller-Bundle (sys._MEIPASS zur Laufzeit gesetzt)."""
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, name)
BG = "#14161b" BG = "#14161b"
CARD_BG = "#1e2129" CARD_BG = "#1e2129"
CARD_BORDER = "#2a2d37" CARD_BORDER = "#2a2d37"
@ -57,6 +68,42 @@ SERIAL_STATUS_TEXT = {
"too_large": "Datenblock zu groß", "too_large": "Datenblock zu groß",
} }
MCP_INFO_TEXT = """Registrierung (einmalig, schon erledigt):
claude mcp add -s user versapad -- <Python313> versapad_mcp_server.py
landet in ~/.claude.json, gilt fuer alle Claude-Code-Sessions.
Lesen:
list_profiles() Profile + lokale Namen
get_profile(profile) alle 20 Buttons + 4 Encoder, lesbar beschriftet
get_macro(slot) Tastenfolge eines Makro-Slots (0-31)
get_board_status() ist das Board erreichbar, welches Profil aktiv
Buttons setzen (profile 0-2, index 0-19):
set_button_key(profile, index, key, modifiers)
set_button_consumer(profile, index, consumer)
set_button_macro(profile, index, slot)
set_button_profile_switch(profile, index, target)
set_button_none(profile, index)
set_button_led(profile, index, r, g, b, anim, period_ms)
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
Makro:
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
Profilname (nur lokal, nicht aufs Board):
rename_profile(profile, name)
Speichern/Laden:
save_local() / load_local() <-> versapad_config_all.json
load_from_board() / write_to_board() <-> echtes Board per Serial
Wichtig: set_*-Aufrufe aendern nur den In-Memory-State. Erst
save_local() oder write_to_board() macht die Aenderung dauerhaft.
Board-Zugriff braucht den COM-Port exklusiv (nicht gleichzeitig mit
VersaGUI oder Live-Sync/Programmiermodus hier in der GUI)."""
class VersaPadViewer(tk.Tk): class VersaPadViewer(tk.Tk):
def __init__(self): def __init__(self):
@ -75,6 +122,21 @@ class VersaPadViewer(tk.Tk):
self._serial_results = queue.Queue() self._serial_results = queue.Queue()
self._serial_thread = threading.Thread(target=self._serial_loop, daemon=True) self._serial_thread = threading.Thread(target=self._serial_loop, daemon=True)
try:
self.iconbitmap(resource_path("icon.ico"))
except tk.TclError:
pass # z.B. kein .ico verfuegbar -- kein Beinbruch, nur Fenster-Icon fehlt dann
self._tray_queue = queue.Queue()
self._tray_icon = pystray.Icon(
"versapad", Image.open(resource_path("icon.png")), "VersaPad Steuermatrix",
menu=pystray.Menu(
pystray.MenuItem("Öffnen", lambda: self._tray_queue.put("show"), default=True),
pystray.MenuItem("Beenden", lambda: self._tray_queue.put("quit")),
),
)
self._tray_icon.run_detached()
header = tk.Frame(self, bg=BG) header = tk.Frame(self, bg=BG)
header.pack(fill="x", padx=20, pady=(18, 4)) header.pack(fill="x", padx=20, pady=(18, 4))
tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT, tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT,
@ -83,6 +145,13 @@ class VersaPadViewer(tk.Tk):
fg=TEXT_DIM, font=("Segoe UI", 9)) fg=TEXT_DIM, font=("Segoe UI", 9))
self.header_sub.pack(anchor="w") self.header_sub.pack(anchor="w")
info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14),
cursor="hand2")
info_btn.place(relx=1.0, x=0, y=-4, anchor="ne")
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
self.tabs = tk.Frame(self, bg=BG) self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(12, 10)) self.tabs.pack(fill="x", padx=20, pady=(12, 10))
self.tab_buttons = {} self.tab_buttons = {}
@ -135,12 +204,14 @@ class VersaPadViewer(tk.Tk):
self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.footer.pack(anchor="w", padx=20, pady=(20, 10)) self.footer.pack(anchor="w", padx=20, pady=(20, 10))
self.protocol("WM_DELETE_WINDOW", self._on_close) self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
self.bind("<Unmap>", self._on_unmap)
self._serial_thread.start() self._serial_thread.start()
self.set_profile(0) self.set_profile(0)
self.after(POLL_MS, self._poll) self.after(POLL_MS, self._poll)
self.after(200, self._drain_serial_queue) self.after(200, self._drain_serial_queue)
self.after(200, self._drain_tray_queue)
# ── Profil-Auswahl ───────────────────────────────────────────────────── # ── Profil-Auswahl ─────────────────────────────────────────────────────
@ -168,6 +239,30 @@ class VersaPadViewer(tk.Tk):
self.combined["profile_names"][profile] = name self.combined["profile_names"][profile] = name
self._update_tab_labels() self._update_tab_labels()
def _show_mcp_info(self):
win = tk.Toplevel(self)
win.title("MCP-Server: versapad")
win.configure(bg=BG)
win.geometry("480x520")
win.transient(self)
tk.Label(win, text="MCP-Server \"versapad\"", bg=BG, fg=TEXT,
font=("Segoe UI", 13, "bold")).pack(anchor="w", padx=16, pady=(16, 4))
tk.Label(win, text="Lässt eine KI (z.B. Claude) die VersaPad-Config direkt per\n"
"Tool-Aufruf lesen/bearbeiten -- ohne JSON von Hand zu schreiben\n"
"oder Klicks in dieser GUI.",
bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9), justify="left").pack(anchor="w", padx=16)
text = tk.Text(win, bg=CARD_BG, fg=TEXT, font=("Consolas", 9), wrap="word",
relief="flat", padx=12, pady=10, bd=0, highlightthickness=0)
text.pack(fill="both", expand=True, padx=16, pady=12)
text.insert("1.0", MCP_INFO_TEXT)
text.configure(state="disabled")
tk.Button(win, text="Schließen", command=win.destroy, bg=CARD_BG, fg=TEXT,
activebackground=ACCENT, activeforeground="#fff", relief="flat",
padx=14, pady=4).pack(pady=(0, 16))
# ── Config-Datei-Polling (nur im Nicht-Edit-Modus relevant) ──────────── # ── Config-Datei-Polling (nur im Nicht-Edit-Modus relevant) ────────────
def _poll(self): def _poll(self):
@ -224,9 +319,43 @@ class VersaPadViewer(tk.Tk):
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler") text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
self.sync_status.configure(text=text, fg=WARN_RED) self.sync_status.configure(text=text, fg=WARN_RED)
def _on_close(self): # ── Tray-Icon (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI) ──
def _on_unmap(self, event):
"""Minimieren faengt Windows normalerweise als Taskleisten-Icon ab --
wir wollen stattdessen: Fenster komplett weg, nur noch Tray-Icon."""
if event.widget is self and self.state() == "iconic":
self._hide_to_tray()
def _hide_to_tray(self):
self.withdraw()
def _show_from_tray(self):
self.deiconify()
self.state("normal")
self.lift()
self.focus_force()
def _drain_tray_queue(self):
try:
while True:
action = self._tray_queue.get_nowait()
if action == "show":
self._show_from_tray()
elif action == "quit":
self._quit()
except queue.Empty:
pass
if not self._closing:
self.after(200, self._drain_tray_queue)
def _quit(self):
self._closing = True self._closing = True
self._link.close() self._link.close()
try:
self._tray_icon.stop()
except Exception:
pass
self.destroy() self.destroy()
# ── Programmiermodus ────────────────────────────────────────── # ── Programmiermodus ──────────────────────────────────────────

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB