diff --git a/Installer.iss b/Installer.iss index abc1035..bce16a2 100644 --- a/Installer.iss +++ b/Installer.iss @@ -1,98 +1,125 @@ -; Installer fuer das Steuerrechnungstool (Inno Setup 6) -; -; ENTSCHEIDUNGEN, DIE HIER STECKEN - und warum -; -------------------------------------------- -; 1. INSTALLATION PRO BENUTZER nach %LOCALAPPDATA%\Programs, NICHT nach -; "Program Files". Grund: der Updater muss die EXE ablegen duerfen. In -; Program Files darf ein normaler Benutzer nicht schreiben - jedes Update -; wuerde an fehlenden Rechten scheitern oder eine Administrator-Abfrage -; erzwingen. Chrome, VS Code, Discord, Teams und Fusion 360 machen es -; genauso, aus demselben Grund. -; -; 2. DIE VERKNUEPFUNG ZEIGT AUF DEN UPDATER, nicht auf das Programm. So schaut -; jeder Start zuerst nach einer neuen Fassung. Der Updater startet danach -; die eigentliche EXE - die ist dabei noch gar nicht in Benutzung und laesst -; sich deshalb problemlos ersetzen. -; -; 3. KEINE DATEN ANFASSEN. Der Installer legt keine Rechnungsordner an, kein -; Journal, kein Nummernbuch - und beim Deinstallieren wird davon nichts -; entfernt. Das Programm schreibt aufbewahrungspflichtige Belege -; (§ 147 AO, zehn Jahre). Ein Deinstallieren darf sie nie mitnehmen. -; -; 4. Der Eintrag unter "Apps & Features" entsteht von selbst - Inno Setup legt -; den Registry-Schluessel unter HKCU an. Da muss niemand von Hand hinein. - -#define Name "Steuerrechnungstool" -#define Herausgeber "ravokk" -#define ExeUpdater "Updater.exe" -#define Webseite "https://git.pcore.de/TheMockTv/Steuerrechnungstool" - -[Setup] -AppId={{B4D71E08-9C36-42AF-8E5B-13A7F0C29D64} -AppName={#Name} -AppVersion={#Version} -AppVerName={#Name} {#Version} -AppPublisher={#Herausgeber} -AppPublisherURL={#Webseite} -AppSupportURL={#Webseite}/issues -AppUpdatesURL={#Webseite}/releases - -; --- pro Benutzer, ohne Adminrechte --- -PrivilegesRequired=lowest -; Der Zielordner ist NICHT waehlbar. Wer hier "Programme" auswaehlen -; koennte, braeche sich den Updater: dort darf ein normaler Benutzer -; nicht schreiben. Chrome, Teams und Discord blenden diese Seite aus -; demselben Grund aus. In "Programme" gehoert hoechstens eine -; Verknuepfung - und die liegt ohnehin im Startmenue. -DisableDirPage=yes -DefaultDirName={localappdata}\Programs\{#Name} -DefaultGroupName={#Name} -DisableProgramGroupPage=yes -UsePreviousAppDir=yes - -OutputDir=setup -OutputBaseFilename=Steuerrechnungstool-Setup-{#Version} -Compression=lzma2 -SolidCompression=yes -WizardStyle=modern -SetupIconFile=assets\programm.ico -UninstallDisplayIcon={app}\{#ExeProgramm} -ArchitecturesInstallIn64BitMode=x64compatible - -; Erprobungsfassung - das steht auch im Programm selbst. -AppComments=Erprobungsfassung. Benutzung auf eigene Gefahr. - -[Languages] -Name: "deutsch"; MessagesFile: "compiler:Languages\German.isl" - -[Tasks] -; Kein "unchecked": der Haken ist vorgesetzt. Er hatte nach dem -; Installieren kein Symbol auf dem Desktop und musste das Programm -; suchen (07.09.2026). Wer es nicht will, nimmt den Haken heraus. -Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \ - GroupDescription: "Zusätzliche Verknüpfungen:" - -[Files] -; Der Updater - das ist, was der Benutzer startet. -Source: "dist\{#ExeUpdater}"; DestDir: "{app}"; Flags: ignoreversion -; Das Programm selbst, mit der Nummer im Namen. Der Updater erkennt daran, -; was installiert ist, und legt neue Fassungen daneben statt darueber. -Source: "dist\{#ExeProgramm}"; DestDir: "{app}"; Flags: ignoreversion - -[Icons] -; Beide Verknuepfungen zeigen auf den UPDATER, nicht auf das Programm. -; IconFilename ausdruecklich: ohne das erbt die Verknuepfung das Symbol vom -; UPDATER, und Windows zeigte an ihrer Stelle das nackte Standardsymbol. -Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}" -Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}" -Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}"; Tasks: desktopicon - -[Run] -Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \ - Flags: nowait postinstall skipifsilent - -[UninstallDelete] -; Nur die Buchfuehrung des Updaters. -; NICHTS von den Daten des Betriebs - keine Rechnungen, kein Journal, kein -; Nummernbuch. Die liegen ohnehin woanders und muessen zehn Jahre bleiben. -Type: files; Name: "{app}\installiert.json" +; Installer fuer das Steuerrechnungstool (Inno Setup 6) +; +; ENTSCHEIDUNGEN, DIE HIER STECKEN - und warum +; -------------------------------------------- +; 1. INSTALLATION PRO BENUTZER nach %LOCALAPPDATA%\Programs, NICHT nach +; "Program Files". Grund: der Updater muss die EXE ablegen duerfen. In +; Program Files darf ein normaler Benutzer nicht schreiben - jedes Update +; wuerde an fehlenden Rechten scheitern oder eine Administrator-Abfrage +; erzwingen. Chrome, VS Code, Discord, Teams und Fusion 360 machen es +; genauso, aus demselben Grund. +; +; 2. DIE VERKNUEPFUNG ZEIGT AUF DEN UPDATER, nicht auf das Programm. So schaut +; jeder Start zuerst nach einer neuen Fassung. Der Updater startet danach +; die eigentliche EXE - die ist dabei noch gar nicht in Benutzung und laesst +; sich deshalb problemlos ersetzen. +; +; 3. KEINE DATEN ANFASSEN. Der Installer legt keine Rechnungsordner an, kein +; Journal, kein Nummernbuch - und beim Deinstallieren wird davon nichts +; entfernt. Das Programm schreibt aufbewahrungspflichtige Belege +; (§ 147 AO, zehn Jahre). Ein Deinstallieren darf sie nie mitnehmen. +; +; 4. Der Eintrag unter "Apps & Features" entsteht von selbst - Inno Setup legt +; den Registry-Schluessel unter HKCU an. Da muss niemand von Hand hinein. + +#define Name "Steuerrechnungstool" +#define Herausgeber "ravokk" +#define ExeUpdater "Updater.exe" +#define Webseite "https://git.pcore.de/TheMockTv/Steuerrechnungstool" + +[Setup] +AppId={{B4D71E08-9C36-42AF-8E5B-13A7F0C29D64} +AppName={#Name} +AppVersion={#Version} +AppVerName={#Name} {#Version} +AppPublisher={#Herausgeber} +AppPublisherURL={#Webseite} +AppSupportURL={#Webseite}/issues +AppUpdatesURL={#Webseite}/releases + +; --- pro Benutzer, ohne Adminrechte --- +PrivilegesRequired=lowest +; Der Zielordner ist NICHT waehlbar. Wer hier "Programme" auswaehlen +; koennte, braeche sich den Updater: dort darf ein normaler Benutzer +; nicht schreiben. Chrome, Teams und Discord blenden diese Seite aus +; demselben Grund aus. In "Programme" gehoert hoechstens eine +; Verknuepfung - und die liegt ohnehin im Startmenue. +DisableDirPage=yes +; EIN Verzeichnis fuer beides - seine Ansage am 07.09.2026: +; "es gibt ravokk als main verzeichnis, da liegt config und sqlite +; drin, und dann gibt es den ordner rechnungstool und steuertool" +; +; %LOCALAPPDATA%\ravokk\ +; campinghof-nummernbuch.sqlite3 <- gemeinsam, beide schauen hierher +; Rechnungstool\ <- Programm + Updater +; Steuerrechnungstool\ <- Programm + Updater +; +; Jedes Programm in seinem eigenen Unterordner: die beiden Updater heissen +; gleich und wuerden sich sonst gegenseitig ueberschreiben. +; +; Das Nummernbuch liegt eine Ebene DARUEBER und gehoert keinem der beiden. +; Ein Deinstallieren nimmt es deshalb nicht mit - und wer nur eines der +; Programme installiert, legt es trotzdem am richtigen Ort an +; (gemeinsam.standard_ordner). +DefaultDirName={localappdata}\ravokk\{#Name} +DefaultGroupName={#Name} +DisableProgramGroupPage=yes +UsePreviousAppDir=yes + +OutputDir=setup +OutputBaseFilename=Steuerrechnungstool-Setup-{#Version} +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +SetupIconFile=assets\programm.ico +UninstallDisplayIcon={app}\{#ExeProgramm} +ArchitecturesInstallIn64BitMode=x64compatible + +; Erprobungsfassung - das steht auch im Programm selbst. +AppComments=Erprobungsfassung. Benutzung auf eigene Gefahr. + +[Languages] +Name: "deutsch"; MessagesFile: "compiler:Languages\German.isl" + +[Tasks] +; Kein "unchecked": der Haken ist vorgesetzt. Er hatte nach dem +; Installieren kein Symbol auf dem Desktop und musste das Programm +; suchen (07.09.2026). Wer es nicht will, nimmt den Haken heraus. +Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \ + GroupDescription: "Zusätzliche Verknüpfungen:" + +[Files] +; Der Updater - das ist, was der Benutzer startet. +Source: "dist\{#ExeUpdater}"; DestDir: "{app}"; Flags: ignoreversion +; Das Programm selbst, mit der Nummer im Namen. Der Updater erkennt daran, +; was installiert ist, und legt neue Fassungen daneben statt darueber. +Source: "dist\{#ExeProgramm}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +; Beide Verknuepfungen zeigen auf den UPDATER, nicht auf das Programm. +; IconFilename ausdruecklich: ohne das erbt die Verknuepfung das Symbol vom +; UPDATER, und Windows zeigte an ihrer Stelle das nackte Standardsymbol. +Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}" +Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}" +Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \ + Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; Was der INSTALLER gelegt hat, raeumt Inno Setup von selbst weg. Hier +; steht, was der UPDATER angelegt hat - davon weiss der Installer nichts: +; -.exe jede geladene Fassung +; installiert.json die Buchfuehrung des Updaters +; Ohne diese Zeilen bleibt der Ordner voller alter EXEs stehen. +; +; NICHT geloescht werden Rechnungen, Journal und Nummernbuch. Die liegen +; ohnehin woanders (%LOCALAPPDATA%/ravokk) und muessen zehn Jahre bleiben +; (§ 147 AO). Deshalb steht hier auch kein 'Type: filesandordirs' auf +; {app} - das waere die bequeme, aber falsche Zeile. +Type: files; Name: "{app}\installiert.json" +Type: files; Name: "{app}\{#Name}-*.exe" +Type: files; Name: "{app}\Updater.exe" +; Zum Schluss den Ordner selbst - aber NUR, wenn er leer ist. +Type: dirifempty; Name: "{app}" \ No newline at end of file diff --git a/Steuerrechnungstool.spec b/Steuerrechnungstool.spec index 98a2ada..a23a205 100644 --- a/Steuerrechnungstool.spec +++ b/Steuerrechnungstool.spec @@ -5,7 +5,7 @@ a = Analysis( ['app.py'], pathex=[], binaries=[], - datas=[('assets/programm.ico', 'assets'), ('assets/programm_dunkel.ico', 'assets'), ('assets/programm_logo.png', 'assets'),], + datas=[('assets/programm.ico', 'assets'), ('assets/programm_logo.png', 'assets'),], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/ablage.py b/ablage.py index 4f75034..7d7970d 100644 --- a/ablage.py +++ b/ablage.py @@ -44,6 +44,43 @@ def _beschreibbar(ordner): return False +def _ist_temp(pfad): + """Liegt dieser Pfad im Temp-Ordner von Windows? + + ⚠ Am 07.09.2026 gefunden: der Merkzettel zeigte auf + %TEMP%/abnahme_bst/Daten - haengengeblieben aus einem Abnahmelauf vom + 03.09. Die INSTALLIERTE Fassung fuehrte ihr Journal seitdem im + Temp-Ordner. Windows raeumt den automatisch auf (Datentraegerbereinigung, + Speicheroptimierung); das Journal waere ohne Vorwarnung verschwunden. + + Ein Merkzettel, der dorthin zeigt, wird deshalb verworfen - lieber ein + frischer, richtiger Ordner als ein Journal auf Abruf. + """ + import tempfile + try: + temp = os.path.realpath(tempfile.gettempdir()).lower() + ziel = os.path.realpath(pfad).lower() + return ziel == temp or ziel.startswith(temp + os.sep) + except (OSError, ValueError): + return False + + +def firmen_ordner(): + """Das gemeinsame Dach: %LOCALAPPDATA%/ravokk. + + Seine Ansage am 07.09.2026: *"es gibt ravokk als main verzeichnis, da + liegt config und sqlite drin, und dann gibt es den ordner rechnungstool + und steuertool"* - und dazu, warum es so heisst: *"ravokk ist meine firma + und die programme gehoeren zu der firma"*. + + Darunter liegt je Programm ein eigener Ordner, und das gemeinsame + Nummernbuch eine Ebene darueber - so nimmt ein Deinstallieren es nicht mit + (siehe gemeinsam.standard_ordner). + """ + heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") + return os.path.join(heim, "ravokk") + + def _zeiger_datei(): """Merkzettel, WO der Datenordner liegt - liegt bewusst ausserhalb davon.""" heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") @@ -106,11 +143,22 @@ def daten_ordner(): log.warning("Datenordner %s ist nicht erreichbar - es wird nichts geändert", gemerkt) return gemerkt if _beschreibbar(gemerkt) else EXE_DIR - kandidat = os.path.join(EXE_DIR, "Daten") + # Erste Wahl: der Ordner der Firma. Dort liegen beide Programme und das + # gemeinsame Nummernbuch - alles an einer Stelle, und ein Deinstallieren + # raeumt nur den Programmordner. + # NEBEN dem Programm, nicht darin - siehe firmen_ordner(). Journal, + # Berichte und Log liegen damit in ravokk, das Programm in + # ravokk/Steuerrechnungstool. Ein Deinstallieren leert nur den + # Programmordner. + kandidat = firmen_ordner() if not _beschreibbar(kandidat): - kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Beherbergungssteuer") + # Rueckfall wie frueher: neben der EXE, dann der alte eigene Ordner. + kandidat = os.path.join(EXE_DIR, "Daten") if not _beschreibbar(kandidat): - return EXE_DIR + kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), + "Beherbergungssteuer") + if not _beschreibbar(kandidat): + return EXE_DIR _ordner_merken(kandidat) for name in EIGENE_DATEN: alt, neu = os.path.join(EXE_DIR, name), os.path.join(kandidat, name) diff --git a/app.py b/app.py index b80e761..e8d93ea 100644 --- a/app.py +++ b/app.py @@ -691,13 +691,25 @@ class App(theme.ThemeMixin, tk.Tk): """ self._buch_oeffnen() if self.buch is None and not TESTLAUF: - self.dlg_gemeinsam(erststart=True) + # Kein Dialog mehr beim ersten Start: der Ordner steht fest und ist + # in beiden Programmen derselbe (gemeinsam.standard_ordner). + ordner = gemeinsam.standard_anlegen() + if ordner: + self.journal.setze("gemeinsam_ordner", ordner) + self._buch_oeffnen() + if self.buch is None: + self.dlg_gemeinsam(erststart=True) self._anmelden() self._buch_fuellen() def dlg_gemeinsam(self, erststart=False): - ordner = ordnerwahl.ordner_abfragen(self, "Steuerjournal", - self.journal.hole("gemeinsam_ordner", "")) + # Ist noch keiner gesetzt, wird der Rechnungsordner vorgeschlagen: + # den kennen beide Programme, er wird mitgesichert, und er ueberlebt + # ein Deinstallieren. Der Ordner, in dem das Programm liegt, taugt + # dafuer NICHT mehr - seit dem Installer sind das zwei getrennte. + ordner = ordnerwahl.ordner_abfragen( + self, "Steuerjournal", + self.journal.hole("gemeinsam_ordner", "") or self.pdf_ordner) if not ordner: if erststart: messagebox.showwarning( diff --git a/assets/programm.ico b/assets/programm.ico index c89df21..8d01085 100644 Binary files a/assets/programm.ico and b/assets/programm.ico differ diff --git a/assets/programm_dunkel.ico b/assets/programm_dunkel.ico deleted file mode 100644 index 46cae9a..0000000 Binary files a/assets/programm_dunkel.ico and /dev/null differ diff --git a/bauen.py b/bauen.py index efd4381..f3aa6dc 100644 --- a/bauen.py +++ b/bauen.py @@ -82,14 +82,15 @@ def main(): for ordner in ("build", "dist", "setup", "release"): shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True) - # Die aufgehellte Fassung des Symbols fuer die dunkle Darstellung. Sie - # wird HIER erzeugt und nicht erst beim ersten dunklen Start: sonst muesste - # das Programm in seinen eigenen Ordner schreiben duerfen - unter - # "Programme" darf es das nicht. + # Das Windows-Symbol aus dem Logo bauen: helle Kachel mit runden Ecken, + # Zeichen darauf, in allen Groessen von 16 bis 256. Es wird HIER erzeugt + # und nicht zur Laufzeit - ein installiertes Programm soll nicht in seinen + # eigenen Ordner schreiben muessen. sys.path.insert(0, HIER) import wasserzeichen - dunkles = wasserzeichen.symbol_datei_dunkel() - print(f"--- Symbol dunkel: {dunkles or 'FEHLT'}") + ico = wasserzeichen.symbol_bauen( + os.path.join(HIER, "assets", "programm.ico")) + print(f"--- Symbol: {ico or 'FEHLT'}") lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", f"{stamm}.spec"], f"{stamm} {version.VERSION}") diff --git a/gemeinsam.py b/gemeinsam.py index 79abae3..7fc329e 100644 --- a/gemeinsam.py +++ b/gemeinsam.py @@ -90,6 +90,49 @@ FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme", "freigabe") +def standard_ordner(): + """Wo das Nummernbuch liegt, wenn niemand etwas anderes einstellt. + + WARUM ES DIESEN FESTEN ORT GIBT (07.09.2026) + -------------------------------------------- + Vorher fragte jedes Programm beim ersten Start nach einem Ordner - und der + Nutzer musste in BEIDEN denselben eintragen. Sein Einwand, wortwoertlich: + + "wie soll das rechnungstool und steuertool wissen wo es ist, das muss + im ... ravokk liegen und dann zusammen, sonst kommt das eine nicht an + das andere ran" + + Er hat recht: die beiden sind ueber genau zwei Dinge verbunden - diese + SQLite-Datei und die kleine API, die "hier ist etwas Neues" ruft. Wenn + schon der Ort geraten werden muss, ist die Verbindung nur so gut wie die + Aufmerksamkeit dessen, der zweimal denselben Pfad tippt. Ein falscher + Buchstabe faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal + vergeben wurde. + + Deshalb: ein fester Ort, den beide von selbst kennen. Gefragt wird nur + noch, wer ihn ausdruecklich verschieben will (Menue -> Gemeinsamer Ordner) - + etwa auf ein Netzlaufwerk, wenn die Programme auf zwei Rechnern laufen. + + LOCALAPPDATA und nicht "Dokumente": dort liegen die Programmdaten ohnehin, + und niemand raeumt den Ordner versehentlich auf. Das Buch ist ein + REGISTER, kein Beleg - geht es verloren, fehlt keine Rechnung, es wird beim + naechsten Einlesen neu gefuellt. + """ + heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") + return os.path.join(heim, "ravokk") + + +def standard_anlegen(): + """Den festen Ordner anlegen und zurueckgeben - oder '' wenn das nicht geht.""" + ordner = standard_ordner() + try: + os.makedirs(ordner, exist_ok=True) + return ordner + except OSError as e: + log.warning("Gemeinsamer Ordner %s liess sich nicht anlegen: %s", ordner, e) + return "" + + def pfad_zur_datei(ordner): """Vollstaendiger Pfad der gemeinsamen Datei in diesem Ordner.""" return os.path.join(ordner or "", DATEINAME) diff --git a/ordnerwahl.py b/ordnerwahl.py index 960a0d5..ddf6fe0 100644 --- a/ordnerwahl.py +++ b/ordnerwahl.py @@ -6,6 +6,14 @@ Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch genannt werden - deshalb fragt jedes Programm beim ersten Start danach und sagt ausdruecklich dazu, dass es derselbe sein muss. +⚠ NICHT der Ordner, in dem das Programm liegt. Frueher stand das so im Text +("am besten der Ordner, in dem die Programme liegen") und der Knopf hiess +sogar "Install-Ordner auswaehlen". Seit es einen Installer gibt, geht das gar +nicht mehr: die beiden Programme liegen in ZWEI getrennten Ordnern unter +%LOCALAPPDATA%/Programs, und ein Deinstallieren wuerde das Nummernbuch +mitnehmen. Vorgeschlagen wird deshalb der Ordner, in dem auch die Rechnungen +liegen - den kennen beide Programme ohnehin, und er wird mitgesichert. + Der Weg ist bewusst umstaendlich: auswaehlen, dann OK, dann noch einmal bestaetigen. Ein falscher Ordner faellt sonst erst auf, wenn eine Rechnungsnummer zum zweiten Mal vergeben wurde. @@ -22,10 +30,11 @@ hinueber. """ import os -import theme import tkinter as tk from tkinter import ttk, filedialog, messagebox +import theme + import gemeinsam BREITE = 780 @@ -40,6 +49,18 @@ class OrdnerWahl(tk.Toplevel): self.ergebnis = None self.title("Gemeinsamer Ordner für das Nummernbuch") self.transient(master) + # Dieses Fenster stand als einziges noch in Weiss da: es kam nie durch + # _style_dialog. Farben holt es sich jetzt vom Hauptfenster, damit es + # nicht wieder auseinanderlaeuft, wenn dort etwas geaendert wird. + self._bg = getattr(master, "col_bg", "#0d0e10") + self._fg = getattr(master, "col_fg", "#e8e8ea") + self._warn = getattr(master, "col_gefahr", "#e85a5a") + self._gut = getattr(master, "col_accent", "#06C6A4") + self._muted = getattr(master, "col_muted", "#8a8e96") + if hasattr(master, "_style_dialog"): + master._style_dialog(self) + else: + self.configure(bg=self._bg) self.pfad = tk.StringVar(value=vorgabe or "") rahmen = ttk.Frame(self, padding=18) @@ -48,7 +69,7 @@ class OrdnerWahl(tk.Toplevel): breite = BREITE - 60 ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen", - font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w") + font=(theme.SCHRIFT_NAME, 13, "bold")).grid(row=0, column=0, sticky="w") ttk.Label(rahmen, wraplength=breite, justify="left", text=( "Rechnungstool und Steuerjournal führen zusammen ein Nummernbuch. Darin steht " "jede vergebene Rechnungsnummer – auch die aus der alten Excel-Tabelle, zu der " @@ -57,10 +78,10 @@ class OrdnerWahl(tk.Toplevel): "eine Nummer ein zweites Mal. Jede Nummer darf es aber nur einmal geben " "(§ 14 Abs. 4 Nr. 4 UStG).")).grid(row=1, column=0, sticky="w", pady=(8, 12)) - tk.Label(rahmen, wraplength=breite, justify="left", fg="#a00000", - font=("Segoe UI", 10, "bold"), + tk.Label(rahmen, wraplength=breite, justify="left", fg=self._warn, bg=self._bg, + font=(theme.SCHRIFT_NAME, 10, "bold"), text="WICHTIG: In BEIDEN Programmen muss derselbe Ordner stehen – am besten " - "der Ordner, in dem die Programme liegen.").grid( + "der Ordner, in dem auch die Rechnungen liegen.").grid( row=2, column=0, sticky="w", pady=(0, 14)) wahl = ttk.Frame(rahmen) @@ -68,11 +89,18 @@ class OrdnerWahl(tk.Toplevel): wahl.columnconfigure(0, weight=1) self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly") self.feld.grid(row=0, column=0, sticky="ew") - ttk.Button(wahl, text="Install-Ordner auswählen…", command=self.waehlen, - width=26).grid(row=0, column=1, sticky="w", padx=(10, 0)) + # "Install-Ordner" hiess der Knopf frueher - das war schon immer + # falsch und ist es seit dem Installer erst recht: hier geht es NICHT + # um den Ordner, in dem das Programm liegt, sondern um den, in dem die + # beiden Programme ihr gemeinsames Nummernbuch fuehren. Sein Befund am + # 07.09.2026: "gibt es noch das popup install ordner auswaehlen, das + # stimmt ja nicht". + ttk.Button(wahl, text="Ordner auswählen…", command=self.waehlen, + width=22).grid(row=0, column=1, sticky="w", padx=(10, 0)) self.stand = tk.Label(rahmen, text="", wraplength=breite, justify="left", - font=("Segoe UI", 9)) + bg=self._bg, + font=(theme.SCHRIFT_NAME, 9)) self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0)) knoepfe = ttk.Frame(rahmen) @@ -129,12 +157,12 @@ class OrdnerWahl(tk.Toplevel): """ ordner = self.pfad.get().strip() if not ordner: - self.stand.configure(text="Noch kein Ordner gewählt.", fg="#a00000") + self.stand.configure(text="Noch kein Ordner gewählt.", fg=self._warn) self.ok.state(["disabled"]) return taugt, grund = gemeinsam.ordner_taugt(ordner) if not taugt: - self.stand.configure(text=grund, fg="#a00000") + self.stand.configure(text=grund, fg=self._warn) self.ok.state(["disabled"]) return self.ok.state(["!disabled"]) @@ -146,27 +174,39 @@ class OrdnerWahl(tk.Toplevel): self.stand.configure( text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – " "das ist der Ordner des anderen Programms. Genau richtig.", - fg="#1f5c3d") + fg=self._gut) else: self.stand.configure( text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im " - "anderen Programm denselben Ordner.", fg="#444444") + "anderen Programm denselben Ordner.", fg=self._muted) + + def _melden(self, titel, text): + """Meldung im Theme des Hauptfensters - sonst wie bisher.""" + eltern = self.master + if hasattr(eltern, "melden_warnung"): + return eltern.melden_warnung(titel, text) + return messagebox.showwarning(titel, text, parent=self) + + def _frage(self, titel, text): + eltern = self.master + if hasattr(eltern, "frage"): + return eltern.frage(titel, text) + return messagebox.askyesno(titel, text, parent=self) + def uebernehmen(self): ordner = self.pfad.get().strip() taugt, grund = gemeinsam.ordner_taugt(ordner) if not taugt: - messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.", - parent=self) + self._melden("Ordner geht nicht", grund or "Bitte einen Ordner wählen.") return - if not messagebox.askyesno( + if not self._frage( "Sind Sie sicher?", f"Gemeinsamer Ordner:\n\n{ordner}\n\n" "Steht im anderen Programm derselbe Ordner? Nur dann kennen beide " "dieselben Rechnungsnummern.\n\n" "Ja = Ordner übernehmen\n" - "Nein = zurück zur Auswahl", - icon="question", default="yes", parent=self): + "Nein = zurück zur Auswahl"): # Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt. self.feld.focus_set() return diff --git a/pruef_gemeinsam_automatisch.py b/pruef_gemeinsam_automatisch.py new file mode 100644 index 0000000..4c9b936 --- /dev/null +++ b/pruef_gemeinsam_automatisch.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: finden sich die beiden Programme von SELBST? + +Sein Einwand vom 07.09.2026, wortwoertlich: + + "wie soll das rechnungstool und steuertool wissen wo es ist, das muss im + ... ravokk liegen und dann zusammen, sonst kommt das eine nicht an das + andere ran - die sind doch nur ueber die gemeinsame sqlite verbunden und + mit api, die sagt hallo da ist was neues" + +Er hat recht: die beiden haengen an genau zwei Faeden - dieser SQLite-Datei +und der kleinen API. Vorher fragte jedes Programm beim ersten Start nach einem +Ordner, und der Nutzer musste in BEIDEN denselben eintragen. Ein Buchstabe +daneben, und jedes Programm fuehrt sein eigenes Buch weiter - auffallen wuerde +das erst, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist +(§ 14 Abs. 4 Nr. 4 UStG). + +GEPRUEFT WIRD + + 1. Beide Programme nennen denselben Standardordner. Das ist die Grundlage; + weichen sie hier ab, hilft alles Weitere nichts. + 2. Der Ordner laesst sich anlegen, und darin entsteht EIN Nummernbuch. + 3. Was das eine eintraegt, findet das andere - ohne dass jemand einen Pfad + eingegeben hat. + +Angefasst wird dabei nichts Echtes: der Standardordner wird ueber die +Umgebungsvariable LOCALAPPDATA auf einen Temp-Ordner umgelenkt. +""" +import os +import shutil +import sys +import tempfile + +fehler = [] + + +def pruefe(bedingung, text): + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kod = sys.stdout.encoding or "utf-8" + print(zeile.encode(kod, "replace").decode(kod)) + if not bedingung: + fehler.append(text) + + +RECHNUNG = r"C:\claude\rechnungstool" +STEUER = r"C:\claude\beherbergungssteuer" + +# ---- 1. Beide Programme meinen denselben Ort ---------------------------- +# Geladen wird jede Fassung EINZELN, damit wirklich die Datei des jeweiligen +# Programms befragt wird und nicht die zufaellig zuerst importierte. +import importlib.util # noqa: E402 + + +def modul_aus(ordner, name): + pfad = os.path.join(ordner, name + ".py") + kennung = f"{name}_{os.path.basename(ordner)}" + spec = importlib.util.spec_from_file_location(kennung, pfad) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +tmp = tempfile.mkdtemp(prefix="gemeinsam_auto_") +os.environ["LOCALAPPDATA"] = tmp + +g_rechnung = modul_aus(RECHNUNG, "gemeinsam") +g_steuer = modul_aus(STEUER, "gemeinsam") + +a, b = g_rechnung.standard_ordner(), g_steuer.standard_ordner() +pruefe(a == b, f"beide Programme nennen denselben Ordner ({a})") +pruefe(a.startswith(tmp), + "und er haengt an LOCALAPPDATA, nicht an einem festen Pfad im Quelltext") +pruefe(os.path.basename(a) == "ravokk", + f"er heisst 'ravokk', wie er es wollte ({os.path.basename(a)})") + +# ---- 2. Er wird angelegt, und darin liegt EIN Buch ---------------------- +angelegt = g_rechnung.standard_anlegen() +pruefe(bool(angelegt) and os.path.isdir(angelegt), + f"der Ordner wird angelegt ({angelegt})") +pruefe(g_rechnung.DATEINAME == g_steuer.DATEINAME, + f"beide erwarten dieselbe Datei ({g_rechnung.DATEINAME})") +pruefe(g_rechnung.pfad_zur_datei(angelegt) == g_steuer.pfad_zur_datei(angelegt), + "und damit denselben vollen Pfad") + +# ---- 3. Was das eine eintraegt, findet das andere ----------------------- +buch_r = g_rechnung.Nummernbuch(angelegt) +buch_s = g_steuer.Nummernbuch(angelegt) + +buch_r.eintragen("2026-777", jahr=2026, nachname="Probe", quelle="rechnungstool") +gefunden = [n for n in buch_s.nummern(2026) if n == "2026-777"] +pruefe(bool(gefunden), + "was das Rechnungstool eintraegt, sieht das Steuerjournal sofort") + +buch_s.eintragen("2026-778", jahr=2026, nachname="Gegenprobe", quelle="journal") +zurueck = [n for n in buch_r.nummern(2026) if n == "2026-778"] +pruefe(bool(zurueck), "und umgekehrt genauso") + +pruefe(len(os.listdir(angelegt)) >= 1 + and any(n.endswith(".sqlite3") for n in os.listdir(angelegt)), + f"es liegt genau ein Buch dort ({os.listdir(angelegt)})") + +shutil.rmtree(tmp, ignore_errors=True) + +if fehler: + print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") + sys.exit(1) +print("GRUEN | Beide Programme finden dasselbe Nummernbuch ohne Nachfrage") diff --git a/pruef_installation.py b/pruef_installation.py new file mode 100644 index 0000000..7e9719c --- /dev/null +++ b/pruef_installation.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: installieren, deinstallieren - bleibt der Ordner sauber? + +Seine Ansage vom 07.09.2026: + + "du solltest die auch mal deinstallieren und neu installieren von windows + lassen, dass du auch pruefen kannst, dass der ordner dann leer ist und + sauber - will keine tools die unsauber sind" + +Er hatte recht. Gemessen blieb nach dem Deinstallieren liegen: + + Rechnungstool-1.7.1.exe, Rechnungstool-1.8.0.exe <- vom UPDATER geladen + Daten/config.json, Daten/rechnungstool.pid + +Die EXEs kennt der Installer nicht - die hat der Updater abgelegt. Ohne eine +eigene Regel bleiben sie stehen, und mit jeder Fassung wird es eine mehr. + +ZWEI DINGE MUESSEN GLEICHZEITIG STIMMEN +--------------------------------------- +1. Der PROGRAMMordner ist danach restlos weg. +2. Die DATEN sind unangetastet - Rechnungen, Journal, Nummernbuch. Die sind + zehn Jahre aufbewahrungspflichtig (§ 147 AO); ein Deinstallieren darf sie + nie mitnehmen. + +Beides zusammen geht nur, weil die Daten NEBEN dem Programm liegen: + + %LOCALAPPDATA%/ravokk/ <- Daten, gemeinsames Nummernbuch + Rechnungstool/ <- Programm, wird geleert + Steuerrechnungstool/ <- Programm, wird geleert + +Genau so hat er es beschrieben: *"es gibt ravokk als main verzeichnis, da liegt +config und sqlite drin, und dann gibt es den ordner rechnungstool und +steuertool"* - und dazu, warum es so heisst: *"ravokk ist meine firma und die +programme gehoeren zu der firma"*. + +⚠ DIESER PRUEFSTAND INSTALLIERT WIRKLICH. Er laeuft deshalb nur, wenn das +Setup gebaut ist, und er fasst NUR die Programmordner an - vorhandene Daten +werden vorher gesichert und danach zurueckgelegt. +""" +import os +import shutil +import subprocess +import sys +import tempfile +import time + +HIER = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HIER) + +import version # noqa: E402 + +LOKAL = os.environ.get("LOCALAPPDATA", "") +RAVOKK = os.path.join(LOKAL, "ravokk") +NAME = os.path.splitext(version.EXE_NAME)[0] +PROGRAMMORDNER = os.path.join(RAVOKK, NAME) + +fehler = [] + + +def pruefe(bedingung, text): + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kod = sys.stdout.encoding or "utf-8" + print(zeile.encode(kod, "replace").decode(kod)) + if not bedingung: + fehler.append(text) + + +def setup_datei(): + ordner = os.path.join(HIER, "release") + if not os.path.isdir(ordner): + return "" + for d in sorted(os.listdir(ordner)): + if d.startswith(NAME + "-Setup") and d.endswith(".exe"): + return os.path.join(ordner, d) + return "" + + +def beenden(): + subprocess.run(["powershell", "-NoProfile", "-Command", + "Get-Process | Where-Object { $_.ProcessName -match " + f"'{NAME}|Updater' " + "} | Stop-Process -Force " + "-ErrorAction SilentlyContinue"], capture_output=True) + time.sleep(1.5) + + +def inhalt(ordner): + if not os.path.isdir(ordner): + return [] + gefunden = [] + for wurzel, _, dateien in os.walk(ordner): + for d in dateien: + gefunden.append(os.path.relpath(os.path.join(wurzel, d), ordner)) + return sorted(gefunden) + + +setup = setup_datei() +if not setup: + print(f"UEBERSPRUNGEN | kein Setup in release/ (vorher: python bauen.py)") + sys.exit(0) +if not LOKAL: + print("UEBERSPRUNGEN | kein LOCALAPPDATA") + sys.exit(0) + +# ---- Vorhandene Daten in Sicherheit bringen ----------------------------- +# Hier laeuft echte Software auf einem echten Rechner. Was schon da ist, wird +# beiseitegelegt und hinterher zurueckgestellt - ein Pruefstand darf niemandem +# seine Buchhaltung anfassen. +sicher = tempfile.mkdtemp(prefix="installpruef_") +gesichert = [] +if os.path.isdir(RAVOKK): + for d in os.listdir(RAVOKK): + quelle = os.path.join(RAVOKK, d) + if os.path.isfile(quelle): + shutil.copy2(quelle, os.path.join(sicher, d)) + gesichert.append(d) +print(f" ({len(gesichert)} vorhandene Datei(en) gesichert)") + +beenden() +alter_uninstaller = os.path.join(PROGRAMMORDNER, "unins000.exe") +if os.path.exists(alter_uninstaller): + subprocess.run([alter_uninstaller, "/VERYSILENT", "/SUPPRESSMSGBOXES", + "/NORESTART"], capture_output=True) + time.sleep(6) + +# ---- 1. Installieren ---------------------------------------------------- +e = subprocess.run([setup, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], + capture_output=True) +time.sleep(5) +pruefe(e.returncode == 0, f"das Setup laeuft durch (Rueckgabe {e.returncode})") +pruefe(os.path.isdir(PROGRAMMORDNER), + f"das Programm landet unter ravokk ({PROGRAMMORDNER})") +dabei = inhalt(PROGRAMMORDNER) +pruefe(any(d.startswith(NAME + "-") for d in dabei), + f"die Programmdatei liegt dort ({dabei})") +pruefe("Updater.exe" in dabei, "und der Updater daneben") + +# ---- 2. Eine Datei anlegen, wie der Updater es taete --------------------- +# So sieht der Ordner aus, nachdem das Programm ein paar Fassungen lang +# gelaufen ist. Genau diese Dateien blieben frueher liegen. +geerbt = os.path.join(PROGRAMMORDNER, f"{NAME}-9.9.9.exe") +with open(geerbt, "wb") as f: + f.write(b"als haette der Updater sie geladen") +with open(os.path.join(PROGRAMMORDNER, "installiert.json"), "w", + encoding="utf-8") as f: + f.write("{}") + +# ---- 3. Eine Datendatei daneben, die ueberleben MUSS --------------------- +probe = os.path.join(RAVOKK, "pruefstand-beleg.txt") +with open(probe, "w", encoding="utf-8") as f: + f.write("steht fuer Rechnungen, Journal und Nummernbuch") + +# ---- 4. Deinstallieren -------------------------------------------------- +beenden() +u = os.path.join(PROGRAMMORDNER, "unins000.exe") +pruefe(os.path.exists(u), "es gibt einen Uninstaller") +if os.path.exists(u): + subprocess.run([u, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], + capture_output=True) + time.sleep(7) + +uebrig = inhalt(PROGRAMMORDNER) +pruefe(not os.path.isdir(PROGRAMMORDNER), + f"der Programmordner ist danach weg ({uebrig})") +pruefe(not uebrig, + f"und darin liegt nichts mehr - auch keine geladene Fassung ({uebrig})") + +pruefe(os.path.exists(probe), + "die Daten daneben sind unangetastet (Rechnungen, Journal, Nummernbuch)") +pruefe(os.path.isdir(RAVOKK), + "und der Ordner der Firma steht noch") + +# ---- Aufraeumen --------------------------------------------------------- +if os.path.exists(probe): + os.remove(probe) +for d in gesichert: + ziel = os.path.join(RAVOKK, d) + if not os.path.exists(ziel): + os.makedirs(RAVOKK, exist_ok=True) + shutil.copy2(os.path.join(sicher, d), ziel) +shutil.rmtree(sicher, ignore_errors=True) + +if fehler: + print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") + sys.exit(1) +print("GRUEN | Deinstallieren laesst den Programmordner leer - und die Daten stehen") diff --git a/wasserzeichen.py b/wasserzeichen.py index 495d72c..7028eca 100644 --- a/wasserzeichen.py +++ b/wasserzeichen.py @@ -45,37 +45,79 @@ def pfad(name): return "" -def symbol_datei_dunkel(): - """Pfad zur aufgehellten ICO - wird angelegt, wenn sie fehlt. +def kachel(bild, kante, grund=(0xFF, 0xFF, 0xFF), rand=0.16, radius=0.22): + """Das Logo auf eine helle Kachel mit runden Ecken setzen. - Sein Befund am 07.09.2026: *"icon musst wenn es dunkel ist mit ändern auf - das hellere geht sonst mit unter"*. Die dunkle Haelfte des Zeichens liegt - genau auf der Farbe der dunklen Titelleiste. + Seine Ansage am 07.09.2026: *"bei den windows icons kannst du einen + weissen bg setzen hinter die icons, mit abgerundeten ecken, und da das svg + drauf - das geht auf dem desktop unter"*. - `iconbitmap` braucht eine DATEI - ein Bild im Speicher genuegt nicht. - Deshalb wird sie einmal neben die normale gelegt. Gelingt das nicht (kein - Schreibrecht im Programmordner), landet sie im Temp-Ordner; und wenn auch - das nicht geht, bleibt es beim normalen Symbol. + Ein freigestelltes Zeichen ohne Flaeche verschwindet auf einem dunklen + Hintergrundbild - und welchen Hintergrund jemand eingestellt hat, weiss + das Programm nicht. Eine helle Kachel ist auf jedem sichtbar; so machen es + die meisten Programme, und deshalb sieht es auch nicht fremd aus. + + `rand` ist der Abstand zum Kachelrand, `radius` die Eckenrundung - beides + als Anteil der Kantenlaenge, damit es in jeder Groesse gleich aussieht. + Die 22 % entsprechen etwa dem, was Windows fuer Kacheln verwendet. + """ + from PIL import Image, ImageDraw + kante = int(kante) + # In vierfacher Groesse zeichnen und herunterrechnen: sonst werden die + # runden Ecken bei 32 oder 48 Pixeln treppig. + fein = kante * 4 + maske = Image.new("L", (fein, fein), 0) + ImageDraw.Draw(maske).rounded_rectangle( + (0, 0, fein - 1, fein - 1), radius=int(fein * radius), fill=255) + flaeche = Image.new("RGBA", (fein, fein), tuple(grund) + (255,)) + flaeche.putalpha(maske) + + innen = max(1, int(fein * (1 - 2 * rand))) + zeichen = bild.convert("RGBA").resize((innen, innen), Image.LANCZOS) + versatz = (fein - innen) // 2 + flaeche.paste(zeichen, (versatz, versatz), zeichen) + return flaeche.resize((kante, kante), Image.LANCZOS) + + +# Groessen, die eine Windows-ICO enthalten soll. 16 und 24 fuer Titelleiste +# und Listen, 32/48 fuer den Desktop, 256 fuer die grosse Ansicht im Explorer. +ICO_GROESSEN = (16, 24, 32, 48, 64, 128, 256) + + +def symbol_bauen(ziel): + """Die Windows-ICO aus dem Logo erzeugen. Gibt den Pfad zurueck oder ''. + + JEDE Groesse wird als Kachel gebaut: helle Flaeche, runde Ecken, Zeichen + darauf - *"so wie das rustdesk, nur halt alle ecken in der rundung"*. Ein + freigestelltes Logo verschwindet sonst auf einem dunklen + Desktop-Hintergrund. + + Auch die 16 Pixel der Titelleiste: dort ist die Kachel zwar winzig, aber + sichtbar. Ein freigestelltes Zeichen war dort je nach Darstellung mal da + und mal nicht - und ein Symbol, das manchmal fehlt, ist schlechter als + eines, das immer klein ist. + + Bei den kleinsten Groessen wird der Rand knapper gefasst: bliebe der + Abstand anteilig gleich, waere vom Zeichen bei 16 Pixeln fast nichts mehr + uebrig. """ - fertig = pfad("programm_dunkel.ico") - if fertig: - return fertig quelle = pfad("programm_logo.png") if not quelle: return "" - ziel = os.path.join(os.path.dirname(quelle), "programm_dunkel.ico") try: from PIL import Image - bild = _umfaerben(Image.open(quelle).convert("RGBA"), True) - groessen = [(g, g) for g in (16, 24, 32, 48, 64, 128, 256)] - try: - bild.save(ziel, sizes=groessen) - except OSError: - import tempfile - ziel = os.path.join(tempfile.gettempdir(), "programm_dunkel.ico") - bild.save(ziel, sizes=groessen) + roh = Image.open(quelle).convert("RGBA") + bilder = [] + for g in ICO_GROESSEN: + bilder.append(kachel(roh, g, rand=0.10 if g <= 24 else 0.16)) + # Pillow schreibt die uebrigen Groessen selbst aus dem groessten Bild - + # deshalb wird das groesste uebergeben und der Rest angehaengt. + bilder[-1].save(ziel, format="ICO", + sizes=[(g, g) for g in ICO_GROESSEN], + append_images=bilder[:-1]) return ziel - except Exception: # noqa: BLE001 - ohne helles Symbol laeuft es weiter + except Exception as e: # noqa: BLE001 - ohne eigenes Symbol laeuft es weiter + print("[symbol] konnte nicht gebaut werden:", e) return "" @@ -86,10 +128,12 @@ def symbol_setzen(fenster, melden=print, dunkel=False): steht in der Titelleiste das nackte Tk-Symbol, obwohl die Datei ein eigenes hat. - In der dunklen Darstellung wird die aufgehellte Fassung genommen, sonst - verschwindet die dunkle Haelfte des Zeichens in der dunklen Titelleiste. + `dunkel` wird nicht mehr gebraucht und nur noch entgegengenommen, damit + bestehende Aufrufe weiterlaufen: seit das Symbol eine helle Kachel ist, ist + es in jeder Darstellung sichtbar. Vorher gab es dafuer eine zweite, + aufgehellte Datei - die ist mit der Kachel ueberfluessig geworden. """ - p = (symbol_datei_dunkel() if dunkel else "") or pfad("programm.ico") + p = pfad("programm.ico") if not p: melden("[symbol] assets/programm.ico nicht gefunden") return False