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
`build_and_deploy.ps1` baut `desktop_viewer.py` mit PyInstaller
(`--windowed --onedir`) und kopiert das Ergebnis lokal nach
`%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. Desktop-Verknüpfung
"VersaPad Viewer (Fenster)" zeigt dorthin.
`build_and_deploy.ps1` kopiert den Quellcode zuerst nach `%TEMP%`, baut dort
mit PyInstaller (`--windowed --onedir --icon icon.ico`), und kopiert das
Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`.
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
öfter AV-Fehlalarme aus (sehen strukturell wie ein Packer aus); `--onedir`
(Ordner mit `.exe` + `_internal`) ist unauffälliger. Trotzdem unsigniert,
also grundsätzlich nie über Smart App Control/AV in Stein gemeißelt sicher.
**Kritischer Stolperstein:** Die gebaute `.exe` läuft NICHT vom Netzlaufwerk
aus (`Z:\Git\...`, SMB-Share auf cj-ki) — Windows blockiert das Nachladen
der `_internal`-DLLs von einem Netzwerkpfad, der Prozess startet dann
einfach nie, ganz ohne Fehlermeldung/Crash-Log/AV-Benachrichtigung (sah
zuerst wie ein AV-Block aus, war aber keiner — sauber verifiziert indem
dieselbe `.exe` 1:1 lokal kopiert sofort lief). Bauen darf auf Z: passieren,
das fertige Bundle muss vor dem Start nach C: kopiert werden — genau das
macht `build_and_deploy.ps1`.
**Zwei Netzlaufwerk-Stolpersteine, deshalb baut UND deployt alles lokal:**
1. Laufzeit: die gebaute `.exe` läuft NICHT vom Netzlaufwerk aus (`Z:\Git\...`,
SMB-Share auf cj-ki) — Windows blockiert das Nachladen der
`_internal`-DLLs von einem Netzwerkpfad, der Prozess startet dann einfach
nie, ganz ohne Fehlermeldung/Crash-Log/AV-Benachrichtigung (sah zuerst wie
ein AV-Block aus, war aber keiner — verifiziert indem dieselbe `.exe` 1:1
lokal kopiert sofort lief).
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
(lokal, nicht über die sandboxed Bash-Tool-Umgebung — die hat eine eigene
Dateisystem-Sicht, die nicht zuverlässig mit dem echten Windows-Dateisystem
übereinstimmt; Kopieroperationen für Dinge, die der User/andere Prozesse
sehen sollen, gehören in PowerShell, nicht in Bash).
übereinstimmt; siehe Sessionlog: ein `cp -r` über Bash landete an einem Ort,
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)

View file

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

View file

@ -1,19 +1,38 @@
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
# nach lokal (C:\Users\<user>\AppData\Local\VersaPadViewer).
#
# WICHTIG: Muss lokal laufen, NICHT direkt vom Netzlaufwerk (Z:\Git\...) --
# Windows blockiert das Nachladen der PyInstaller-_internal-DLLs von einem
# Netzwerkpfad (SMB-Share) ohne jede Fehlermeldung, der Prozess startet
# einfach nie. Deshalb bauen wir hier (Quelle darf auf Z: liegen) und
# kopieren das fertige Bundle danach nach C:.
# WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf
# dem Netzlaufwerk (Z:\Git\...):
# - Laufzeit: Windows blockiert das Nachladen der PyInstaller-_internal-
# DLLs von einem Netzwerkpfad (SMB-Share) ohne jede Fehlermeldung, der
# 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"
$projectDir = $PSScriptRoot
$buildSrc = "$env:TEMP\versapad_build_src"
$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 {
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 {
Pop-Location
}
@ -21,6 +40,7 @@ try {
if (Test-Path $localDir) {
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"

View file

@ -21,17 +21,28 @@ Start: python desktop_viewer.py
"""
import os
import queue
import sys
import threading
import time
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog
import pystray
from PIL import Image
import action_dialog
import versapad_combined as vcomb
import versapad_data as vp
import versapad_protocol as vproto
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"
CARD_BG = "#1e2129"
CARD_BORDER = "#2a2d37"
@ -57,6 +68,42 @@ SERIAL_STATUS_TEXT = {
"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):
def __init__(self):
@ -75,6 +122,21 @@ class VersaPadViewer(tk.Tk):
self._serial_results = queue.Queue()
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.pack(fill="x", padx=20, pady=(18, 4))
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))
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.pack(fill="x", padx=20, pady=(12, 10))
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.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.set_profile(0)
self.after(POLL_MS, self._poll)
self.after(200, self._drain_serial_queue)
self.after(200, self._drain_tray_queue)
# ── Profil-Auswahl ─────────────────────────────────────────────────────
@ -168,6 +239,30 @@ class VersaPadViewer(tk.Tk):
self.combined["profile_names"][profile] = name
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) ────────────
def _poll(self):
@ -224,9 +319,43 @@ class VersaPadViewer(tk.Tk):
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
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._link.close()
try:
self._tray_icon.stop()
except Exception:
pass
self.destroy()
# ── 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