diff --git a/AGENTS.md b/AGENTS.md index 2faadb5..99af9a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (`` 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) diff --git a/VersaPadViewer.spec b/VersaPadViewer.spec index fc2858d..c41bbc3 100644 --- a/VersaPadViewer.spec +++ b/VersaPadViewer.spec @@ -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, diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index 23318b4..8243798 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -1,19 +1,38 @@ # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis # nach lokal (C:\Users\\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" diff --git a/desktop_viewer.py b/desktop_viewer.py index 02e6fd5..2c913ed 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -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 -- 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("", lambda e: self._show_mcp_info()) + info_btn.bind("", lambda e: info_btn.configure(fg=ACCENT)) + info_btn.bind("", 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("", 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 ────────────────────────────────────────── diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..3055bcb Binary files /dev/null and b/icon.ico differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..c7b7b04 Binary files /dev/null and b/icon.png differ