Kopflogo, Bau an einer Stelle, Startprobe fuer die EXE
Logo Das Programmlogo (Wolke mit Paragraphenzeichen) steht jetzt rechts in der Kopfleiste - damit sich die beiden Werkzeuge auf einen Blick unterscheiden. bauen.py Der Weg von der Quelle zur Setup-Datei war eine Folge von Befehlen, die jemand von Hand eintippen musste. Wer einen Schritt vergisst, liefert eine Setup-Datei mit einer alten EXE aus - und sieht es ihr nicht an. Jetzt ist es ein Befehl, die Nummer kommt aus version.py, und am Ende stehen die SHA256-Summen fuer den Release-Text. pruef_start_exe.py Prueft, was kein anderer Pruefstand prueft: ob die GEBAUTE EXE ein sichtbares Fenster zeigt. Alle uebrigen fahren das Programm aus der Quelle, dort trat der Fehler nie auf. pruef_einzelinstanz.py wieder gruen Der Erprobungshinweis haette in jedem der drei Starts ein echtes Fenster geoeffnet - in einem eigenen Prozess laesst sich kein Dialog abfangen, es klickt niemand. Geprueft wird dort die SPERRE, nicht der Hinweis (der hat einen eigenen Pruefstand). Die Kenntnisnahme wird deshalb vorher eingetragen, und zwar mit genau dem Code, den auch der Klick auf "Ja" ausfuehrt. Das trifft nur die Kopie im Temp-Ordner: im echten Journal entsteht keine Zustimmung, die niemand gegeben hat. Aufgeraeumt Beherbergungssteuer.exe und die gleichnamige .spec sind raus - das Programm heisst jetzt anders. Gebaute Dateien gehoeren ins Release, nicht ins Repo; die .spec-Dateien dagegen schon. Fassung 1.8.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
parent
b9094e7ebb
commit
96aa51ac22
17 changed files with 688 additions and 27 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -10,3 +10,12 @@ __pycache__/
|
|||
build/
|
||||
dist/
|
||||
*.spec
|
||||
|
||||
# Gebaute Dateien - die gehoeren ins Release, nicht ins Repo.
|
||||
*.exe
|
||||
setup/
|
||||
|
||||
# Die Bauanleitungen selbst gehoeren SEHR WOHL hinein: ohne sie kann niemand
|
||||
# die EXE nachbauen, die der Updater ausliefert.
|
||||
!Steuerrechnungstool.spec
|
||||
!Updater.spec
|
||||
|
|
|
|||
Binary file not shown.
93
Installer.iss
Normal file
93
Installer.iss
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
; 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]
|
||||
Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \
|
||||
GroupDescription: "Zusätzliche Verknüpfungen:"; Flags: unchecked
|
||||
|
||||
[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.
|
||||
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"
|
||||
Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}"
|
||||
Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; 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"
|
||||
39
Steuerrechnungstool.spec
Normal file
39
Steuerrechnungstool.spec
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['app.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('assets/programm.ico', 'assets'), ('assets/programm_logo.png', 'assets'),],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='Steuerrechnungstool',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
icon='assets/programm.ico',
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
24
Updater.spec
Normal file
24
Updater.spec
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# Der Updater ist der Starter: winzige Konsolen-EXE, die vor dem Programm laeuft.
|
||||
a = Analysis(
|
||||
['updater.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=['version'],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=['tkinter', 'reportlab', 'PIL', 'pypdf', 'pypdfium2', 'numpy'],
|
||||
noarchive=False,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
exe = EXE(
|
||||
pyz, a.scripts, a.binaries, a.datas, [],
|
||||
name='Updater',
|
||||
debug=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
icon='assets/programm.ico',
|
||||
)
|
||||
55
app.py
55
app.py
|
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Steuerrechnungstool – Steuerjournal für den Campinghof Bartl.
|
||||
Steuerrechnungstool – das Steuerjournal zum Rechnungstool.
|
||||
|
||||
Ersetzt die Excel-Mappe: Die Rechnungs-PDFs aus dem Rechnungstool sind die
|
||||
Quelle, das Programm liest sie ein, führt das Journal (SQLite) und schreibt
|
||||
|
|
@ -26,6 +26,7 @@ from tkinter import ttk, filedialog, messagebox
|
|||
# Nur beim echten Start: Pruefstaende importieren app als Modul und sollen
|
||||
# nicht an der Sperre des laufenden Programms scheitern.
|
||||
import hinweis
|
||||
import wasserzeichen
|
||||
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen,
|
||||
fenster_nach_vorn, laufende_pid_lesen)
|
||||
|
||||
|
|
@ -79,7 +80,8 @@ log = logging.getLogger("bst.app")
|
|||
class App(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("Steuerrechnungstool – Campinghof Bartl")
|
||||
self.title("Steuerrechnungstool")
|
||||
self._fenstersymbol()
|
||||
self.geometry("1000x680")
|
||||
self.minsize(820, 520)
|
||||
|
||||
|
|
@ -103,6 +105,7 @@ class App(tk.Tk):
|
|||
self.bind("<Control-f>", lambda _e: self.suchfeld.focus_set())
|
||||
self.protocol("WM_DELETE_WINDOW", self._beenden)
|
||||
self.aktualisieren()
|
||||
self._wasserzeichen_setzen()
|
||||
|
||||
# NICHTS laeuft an, bevor der Erprobungshinweis bestaetigt ist - sonst
|
||||
# stuenden beim Ablehnen Timer in der Warteschlange, die auf ein
|
||||
|
|
@ -110,11 +113,12 @@ class App(tk.Tk):
|
|||
self._briefkasten_stand = "?" # "?" = noch nicht nachgesehen
|
||||
self._push_kasten = queue.Queue() # Zettel aus dem API-Faden
|
||||
self._nachricht_stand = 0 # bis hierher sind Meldungen gelesen
|
||||
# after_idle statt after(0): laeuft, sobald Tk nichts mehr zu zeichnen hat -
|
||||
# also nachdem das Hauptfenster steht. Bei after(0) haengt der modale Dialog
|
||||
# an einem noch ungezeichneten Fenster und wirkt wie eingefroren; eine feste
|
||||
# Wartezeit dagegen erreicht ein Pruefstand nie, der keine Schleife dreht.
|
||||
self.after_idle(self._hinweis_pruefen)
|
||||
# Der Hinweis darf erst aufgehen, wenn das Hauptfenster WIRKLICH
|
||||
# gezeichnet ist. after_idle reicht dafuer nicht: in der gepackten
|
||||
# EXE ist das Fenster dann noch nicht sichtbar, der modale Dialog
|
||||
# sperrt es - und beide bleiben unsichtbar. Das sah aus wie ein
|
||||
# haengendes Programm (06.09.2026, am Installer aufgefallen).
|
||||
self.after(50, self._hinweis_wenn_sichtbar)
|
||||
|
||||
# ------------------------------------------------- Erprobungshinweis
|
||||
def _hinweis_ablage(self):
|
||||
|
|
@ -132,6 +136,36 @@ class App(tk.Tk):
|
|||
|
||||
return Ablage()
|
||||
|
||||
def _fenstersymbol(self):
|
||||
"""Das Programmsymbol auf das Fenster legen."""
|
||||
return wasserzeichen.symbol_setzen(self, melden=log.info)
|
||||
|
||||
def _wasserzeichen_setzen(self, groesse=220):
|
||||
"""Das Programmlogo blass in die untere rechte Ecke."""
|
||||
bild = wasserzeichen.hole(groesse, 0.06, master=self, melden=log.info)
|
||||
if bild is None:
|
||||
return
|
||||
# Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk
|
||||
# zeigt eine leere Flaeche.
|
||||
self._wz_bild = bild
|
||||
self._wz = tk.Label(self, image=bild, borderwidth=0)
|
||||
self._wz.place(relx=1.0, rely=1.0, anchor='se', x=-14, y=-30)
|
||||
self._wz.lower()
|
||||
|
||||
def _hinweis_wenn_sichtbar(self, versuche=0):
|
||||
"""Wartet, bis das Fenster gezeichnet ist, und fragt dann.
|
||||
|
||||
winfo_viewable() sagt, ob das Fenster tatsaechlich auf dem Schirm
|
||||
ist. Solange nicht, wird in kurzen Abstaenden nachgesehen. Nach
|
||||
rund zwei Sekunden wird trotzdem gefragt - im Pruefstand ist das
|
||||
Fenster absichtlich nicht sichtbar, und dort soll es trotzdem
|
||||
laufen.
|
||||
"""
|
||||
if not self.winfo_viewable() and versuche < 40:
|
||||
self.after(50, lambda: self._hinweis_wenn_sichtbar(versuche + 1))
|
||||
return
|
||||
self._hinweis_pruefen()
|
||||
|
||||
def _hinweis_pruefen(self):
|
||||
"""Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm."""
|
||||
self.update_idletasks() # Fenster fertig zeichnen lassen
|
||||
|
|
@ -225,6 +259,13 @@ class App(tk.Tk):
|
|||
leiste = ttk.Frame(self, padding=(10, 8))
|
||||
leiste.pack(fill="x")
|
||||
|
||||
# Das Programmlogo rechts in der Kopfzeile - das Erkennungszeichen.
|
||||
# Es steht rechts, damit es die Bedienelemente nicht nach hinten
|
||||
# schiebt, wenn es einmal fehlt.
|
||||
self._kopf_bild = wasserzeichen.kopf(28, master=self, melden=log.info)
|
||||
if self._kopf_bild is not None:
|
||||
ttk.Label(leiste, image=self._kopf_bild).pack(side="right")
|
||||
|
||||
ttk.Label(leiste, text="Jahr:").pack(side="left")
|
||||
self.jahr_wahl = ttk.Combobox(leiste, width=6, state="readonly",
|
||||
textvariable=self.jahr, values=self._jahre())
|
||||
|
|
|
|||
BIN
assets/programm.ico
Normal file
BIN
assets/programm.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/programm_logo.png
Normal file
BIN
assets/programm_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
27
assets/programm_logo.svg
Normal file
27
assets/programm_logo.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,-566.85,0)">
|
||||
<g id="Artboard2" transform="matrix(1,0,0,1,566.85,0)">
|
||||
<rect x="0" y="0" width="512" height="512" style="fill:none;"/>
|
||||
<g transform="matrix(1.94766,0,0,2.05245,-243.041,-218.664)">
|
||||
<g id="Links">
|
||||
<path d="M222.131,215.66L222.13,208.593L238.702,219.494L222.133,230.399L222.131,215.66ZM216.151,130.644C218.188,126.804 220.833,123.524 224.142,119.685C234.021,108.226 250.634,106.769 256,106.556C265.488,106.18 280.606,111.644 287.029,118.435C289.041,120.563 302.548,133.524 301.132,155.668L293.14,155.646C293.608,141.507 288.592,132.589 281.714,125.1C276.056,118.94 266.39,114.408 256,114.561C242.327,114.761 218.593,124.295 219.086,155.601L210.89,155.613C211.196,148.263 211.937,142.778 213.154,138.311C205.649,135.18 198.203,136.466 193.569,139.394C183.234,145.923 181.557,158.851 184.735,165.224L184.044,165.286C162.105,162.842 155.274,179.91 154.506,190.518C153.545,203.798 166.473,214.688 176.844,215.811L222.131,215.66L222.13,223.927L175.884,223.738C156.922,221.252 145.975,205.354 146.25,190.467C146.541,174.685 156.471,160.659 175.619,157.066C176.48,145.398 181.102,139.165 187.459,134.119C195.54,127.705 207.218,127.153 216.151,130.644Z" style="fill:rgb(18,89,109);"/>
|
||||
<path d="M228.019,252.582L228.078,252.582C227.944,248.705 228.245,244.088 231.648,238.835C232.85,236.98 237.587,231.659 244.591,228.229C248.987,226.077 251.828,221.17 251.928,217.367L252.1,180.838L241.711,180.855L257.771,156.928L257.771,143.768L227.834,188.271L245.057,188.511L244.885,216.83C244.671,218.4 244.258,219.657 242.763,220.467C234.731,224.82 225.142,231.462 228.019,252.582Z" style="fill:rgb(14,79,101);"/>
|
||||
</g>
|
||||
<g id="Rechts">
|
||||
<g transform="matrix(-0.988012,0,0,1,508.931,0)">
|
||||
<path d="M237.047,251.22C237.178,239.336 245.86,235.025 256.588,236.213L256.575,235.972C252.442,233.264 248.389,227.365 248.529,222.683L248.522,180.85L237.787,180.865L254.207,157.002L254.207,143.768L223.645,188.5L241.142,188.545L241.351,222.218C241.342,223.858 241.699,225.564 241.829,226.526L241.828,226.772C228.424,230.549 224.979,242.528 224.456,251.314L237.047,251.22Z" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<path d="M290.321,215.66L335.608,215.811C345.979,214.688 358.907,203.798 357.946,190.518C357.178,179.91 350.347,162.842 328.408,165.286L327.717,165.224C330.895,158.851 329.218,145.923 318.883,139.394C312.886,135.605 304.481,133.711 292.087,142.516L288.929,134.747C297.75,127.533 314.633,125.896 324.993,134.119C331.35,139.165 335.972,145.398 336.833,157.066C355.981,160.659 365.911,174.685 366.202,190.467C366.477,205.354 355.53,221.252 336.568,223.738L290.322,223.927L290.321,215.66ZM290.321,215.66L290.319,230.399L273.75,219.494L290.322,208.593L290.321,215.66Z" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.584177,0,0,0.584177,-224.63,106.538)">
|
||||
<path d="M851.907,348.191L860.766,356.196C875.59,345.97 881.708,334.438 881.778,321.058C881.863,304.967 873.648,293.16 861.58,285.668L812.266,258.32C800.634,252.557 789.781,232.581 806.866,214.138L806.854,213.679C771.946,231.774 777.593,260.635 790.222,270.44L799.315,277.718C818.663,292.911 843.341,301.213 850.523,307.455L850.991,307.887C862.325,316.409 870.637,330.48 851.907,348.191Z" style="fill:rgb(56,70,82);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.584177,0,0,0.584177,-224.63,106.538)">
|
||||
<path d="M775.465,379.883C777.214,420.92 813.794,430.9 837.42,425.799C854.616,422.087 866.406,413.927 871.906,398.762C874.547,391.476 876.695,367.794 861.667,355.002L853.134,346.714L803.048,318.965C791.6,310.022 779.665,299.004 799.076,277.362L790.324,270.421C776.315,279.645 765.735,298.665 772.73,317.019C775.78,325.023 779.864,332.602 796.367,342.874L842.363,369.055C866.733,390.878 840.321,405.594 834.774,406.552C802.323,412.156 798.439,391.489 795.956,379.852L775.465,379.883Z" style="fill:rgb(56,70,82);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
109
bauen.py
Normal file
109
bauen.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Der ganze Weg von der Quelle zur Setup-Datei - an EINER Stelle.
|
||||
|
||||
WARUM DAS HIER STEHT
|
||||
--------------------
|
||||
Bis hierher lag der Bau in einer Folge von Befehlen, die jemand von Hand
|
||||
eintippen musste: PyInstaller fuers Programm, PyInstaller fuer den Updater,
|
||||
umbenennen, Inno Setup mit den richtigen Schaltern. Wer einen Schritt
|
||||
vergisst, liefert eine Setup-Datei aus, die eine alte EXE enthaelt - und
|
||||
niemand sieht es ihr an. Genau das ist am 06.09.2026 passiert (der Installer
|
||||
schrieb ueber eine laufende, gesperrte EXE hinweg und meldete trotzdem Erfolg).
|
||||
|
||||
Ab jetzt ist der Bau ein Befehl:
|
||||
|
||||
python bauen.py
|
||||
|
||||
WAS ER TUT
|
||||
----------
|
||||
1. Die Nummer kommt aus version.py - nicht von der Kommandozeile. So kann sie
|
||||
nicht auseinanderlaufen mit dem, was das Programm ueber sich selbst sagt.
|
||||
2. Programm und Updater werden gebaut.
|
||||
3. Die Programm-EXE bekommt die Nummer in den Namen. Der Updater erkennt daran,
|
||||
welche Fassungen installiert sind, und legt neue DANEBEN statt darueber -
|
||||
deshalb kann er auch dann austauschen, wenn Windows die alte Datei sperrt.
|
||||
4. Inno Setup baut die Setup-Datei.
|
||||
5. Zum Schluss stehen die SHA256-Summen da. Die gehoeren in den Release-Text:
|
||||
nur damit laesst sich spaeter nachweisen, dass die heruntergeladene Datei
|
||||
genau die gebaute ist.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HIER)
|
||||
|
||||
import version # noqa: E402
|
||||
|
||||
# Inno Setup liegt bei ihm unter LOCALAPPDATA (Installation ohne Adminrechte).
|
||||
ISCC_ORTE = [
|
||||
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Inno Setup 6", "ISCC.exe"),
|
||||
r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
||||
r"C:\Program Files\Inno Setup 6\ISCC.exe",
|
||||
]
|
||||
|
||||
|
||||
def summe(pfad):
|
||||
h = hashlib.sha256()
|
||||
with open(pfad, "rb") as f:
|
||||
for brocken in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(brocken)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def lauf(befehl, was):
|
||||
print(f"--- {was}")
|
||||
e = subprocess.run(befehl, cwd=HIER, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace")
|
||||
if e.returncode != 0:
|
||||
print(e.stdout[-3000:])
|
||||
print(e.stderr[-3000:])
|
||||
raise SystemExit(f"{was} fehlgeschlagen (Rueckgabe {e.returncode})")
|
||||
|
||||
|
||||
def iscc():
|
||||
for p in ISCC_ORTE:
|
||||
if p and os.path.exists(p):
|
||||
return p
|
||||
raise SystemExit("ISCC.exe nicht gefunden - Inno Setup 6 fehlt")
|
||||
|
||||
|
||||
def main():
|
||||
stamm, endung = os.path.splitext(version.EXE_NAME)
|
||||
mit_nummer = f"{stamm}-{version.VERSION}{endung}"
|
||||
|
||||
# Die Bauordner werden geleert. Ein PyInstaller-Lauf ueber einen alten
|
||||
# Bauordner uebernimmt sonst Reste, die es in der Quelle nicht mehr gibt -
|
||||
# und in setup/ laege noch die Datei der VORIGEN Nummer herum. Beim
|
||||
# Ausliefern greift man dann leicht zur falschen.
|
||||
for ordner in ("build", "dist", "setup"):
|
||||
shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True)
|
||||
|
||||
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", f"{stamm}.spec"],
|
||||
f"{stamm} {version.VERSION}")
|
||||
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", "Updater.spec"],
|
||||
"Updater")
|
||||
|
||||
quelle = os.path.join(HIER, "dist", version.EXE_NAME)
|
||||
ziel = os.path.join(HIER, "dist", mit_nummer)
|
||||
os.replace(quelle, ziel)
|
||||
|
||||
lauf([iscc(), f"/DVersion={version.VERSION}", f"/DExeProgramm={mit_nummer}",
|
||||
"Installer.iss"], "Setup")
|
||||
|
||||
print()
|
||||
print(f"FERTIG | {stamm} {version.VERSION}")
|
||||
for name in sorted(os.listdir(os.path.join(HIER, "dist"))):
|
||||
p = os.path.join(HIER, "dist", name)
|
||||
print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}")
|
||||
setup = os.path.join(HIER, "setup")
|
||||
for name in sorted(os.listdir(setup)) if os.path.isdir(setup) else []:
|
||||
p = os.path.join(setup, name)
|
||||
print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Liest eine Rechnungs-PDF des Rechnungstools (Campinghof Bartl) und macht
|
||||
Liest eine Rechnungs-PDF des Rechnungstools und macht
|
||||
daraus eine Buchung fürs Steuerjournal.
|
||||
|
||||
Der PDF-Text ist zeilenweise aufgebaut: erst das Label, dann in der
|
||||
|
|
|
|||
|
|
@ -67,6 +67,47 @@ for name in os.listdir(HIER):
|
|||
shutil.copy(os.path.join(HIER, name), os.path.join(prog, name))
|
||||
|
||||
|
||||
# Auch die Beilagen mitnehmen - ohne assets/ faende das Programm sein Logo
|
||||
# nicht. Das stuerzt zwar nicht ab, aber ein Pruefstand soll das Programm
|
||||
# fahren, wie es ausgeliefert wird, nicht eine abgespeckte Fassung.
|
||||
for name in ("assets",):
|
||||
quelle = os.path.join(HIER, name)
|
||||
if os.path.isdir(quelle):
|
||||
shutil.copytree(quelle, os.path.join(prog, name))
|
||||
|
||||
|
||||
# Der Erprobungshinweis wuerde in JEDEM dieser drei Starts ein echtes Fenster
|
||||
# oeffnen. Hier laeuft das Programm als eigener Prozess - da laesst sich kein
|
||||
# Dialog abfangen, es klickt niemand, und der Pruefstand liefe in die
|
||||
# Reissleine. Geprueft wird an dieser Stelle die SPERRE; der Hinweis hat seinen
|
||||
# eigenen Pruefstand (pruef_hinweis.py).
|
||||
#
|
||||
# Also wird die Kenntnisnahme vorher eingetragen - mit genau dem Code, den auch
|
||||
# der Klick auf "Ja" ausfuehrt (hinweis.vermerken auf die Einstellungstabelle).
|
||||
# Nichts nachgebaut, nichts von Hand ins SQL geschrieben.
|
||||
#
|
||||
# Das trifft NUR die Kopie im Temp-Ordner: aus der Quelle heraus liegt der
|
||||
# Datenordner neben app.py (ablage.daten_ordner()). Im echten Journal des
|
||||
# Betriebs entsteht dadurch keine Zustimmung, die niemand gegeben hat.
|
||||
def hinweis_vorab_eintragen():
|
||||
import db
|
||||
import hinweis
|
||||
journal = db.Journal(os.path.join(prog, "journal.sqlite3"))
|
||||
|
||||
class Ablage:
|
||||
def get(self, schluessel, standard=""):
|
||||
return journal.hole(schluessel, standard)
|
||||
|
||||
def __setitem__(self, schluessel, wert):
|
||||
journal.setze(schluessel, str(wert))
|
||||
|
||||
hinweis.vermerken(Ablage())
|
||||
print(f" (Kenntnisnahme in der Kopie vermerkt, Fassung {hinweis.FASSUNG})")
|
||||
|
||||
|
||||
hinweis_vorab_eintragen()
|
||||
|
||||
|
||||
def starte():
|
||||
# --test: kein Erststart-Dialog nach dem gemeinsamen Ordner. Die Sperre
|
||||
# selbst greift unabhaengig davon - genau die wird hier geprueft.
|
||||
|
|
|
|||
137
pruef_start_exe.py
Normal file
137
pruef_start_exe.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: kommt bei der GEBAUTEN EXE wirklich ein Fenster?
|
||||
|
||||
DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026)
|
||||
--------------------------------------------
|
||||
Nach dem Installieren lief das Programm - im Taskmanager stand es da, es
|
||||
verbrauchte Rechenzeit - und auf dem Bildschirm war NICHTS. Ursache: der
|
||||
Erprobungshinweis ging auf, bevor das Hauptfenster gezeichnet war. Der modale
|
||||
Dialog sperrte das Hauptfenster, und beide blieben unsichtbar.
|
||||
|
||||
WARUM DAS KEIN ANDERER PRUEFSTAND FINDET
|
||||
----------------------------------------
|
||||
Alle uebrigen Pruefstaende fahren das Programm aus der QUELLE. Dort ist das
|
||||
Fenster sofort gezeichnet, der Fehler tritt nicht auf. Er gehoert zur gepackten
|
||||
EXE - also muss genau die gestartet werden.
|
||||
|
||||
Und "der Prozess laeuft" beweist nichts: er lief ja. Geprueft wird deshalb, ob
|
||||
Windows ein Fenster dieses Programms als SICHTBAR fuehrt.
|
||||
|
||||
ZWEI FALLEN, die hier schon zugeschnappt sind:
|
||||
* Eine PyInstaller-Onefile-EXE ist zweistufig. Der gestartete Prozess packt
|
||||
nur aus; das Fenster gehoert dem KIND. Wer nur die eigene PID betrachtet,
|
||||
sieht nie ein Fenster.
|
||||
* terminate() beendet nur den Auspacker. Das Kind laeuft weiter - und dessen
|
||||
Einzelinstanz-Sperre haelt danach jeden weiteren Start ab. Es muss die
|
||||
ganze Sippe beendet werden.
|
||||
"""
|
||||
import ctypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HIER)
|
||||
|
||||
import version # noqa: E402
|
||||
|
||||
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)
|
||||
|
||||
|
||||
stamm, endung = os.path.splitext(version.EXE_NAME)
|
||||
EXE = os.path.join(HIER, "dist", f"{stamm}-{version.VERSION}{endung}")
|
||||
|
||||
if not os.path.exists(EXE):
|
||||
print(f"UEBERSPRUNGEN | {os.path.basename(EXE)} ist nicht gebaut "
|
||||
f"(vorher: python bauen.py)")
|
||||
sys.exit(0)
|
||||
|
||||
u = ctypes.windll.user32
|
||||
p = subprocess.Popen([EXE])
|
||||
print(f" (gestartet: {os.path.basename(EXE)}, PID {p.pid})")
|
||||
|
||||
RUECKRUF = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
gefunden = []
|
||||
verwandte = {p.pid}
|
||||
|
||||
|
||||
def sippe():
|
||||
alle = {p.pid}
|
||||
try:
|
||||
e = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
"Get-CimInstance Win32_Process | Where-Object "
|
||||
f"{{ $_.ParentProcessId -eq {p.pid} }} | ForEach-Object {{ $_.ProcessId }}"],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
for zeile in e.stdout.split():
|
||||
if zeile.strip().isdigit():
|
||||
alle.add(int(zeile.strip()))
|
||||
except Exception: # noqa: BLE001 - dann bleibt es bei der eigenen PID
|
||||
pass
|
||||
return alle
|
||||
|
||||
|
||||
def je_fenster(hwnd, _):
|
||||
pid = wintypes.DWORD()
|
||||
u.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
if pid.value not in verwandte:
|
||||
return True
|
||||
laenge = u.GetWindowTextLengthW(hwnd)
|
||||
puffer = ctypes.create_unicode_buffer(laenge + 1)
|
||||
u.GetWindowTextW(hwnd, puffer, laenge + 1)
|
||||
r = wintypes.RECT()
|
||||
u.GetWindowRect(hwnd, ctypes.byref(r))
|
||||
gefunden.append((puffer.value, bool(u.IsWindowVisible(hwnd)),
|
||||
r.right - r.left, r.bottom - r.top))
|
||||
return True
|
||||
|
||||
|
||||
ende = time.time() + 60
|
||||
sichtbar = False
|
||||
vorzeitig_weg = False
|
||||
while time.time() < ende and not sichtbar:
|
||||
if p.poll() is not None:
|
||||
vorzeitig_weg = True
|
||||
break
|
||||
verwandte = sippe()
|
||||
gefunden.clear()
|
||||
u.EnumWindows(RUECKRUF(je_fenster), 0)
|
||||
# Ein "Fenster" von wenigen Pixeln ist Verwaltung (IME, Notifier), kein
|
||||
# Programmfenster. Deshalb die Groesse mitpruefen.
|
||||
sichtbar = any(s and b > 300 and h > 300 for _, s, b, h in gefunden)
|
||||
time.sleep(0.5)
|
||||
|
||||
for titel, s, b, h in gefunden:
|
||||
print(f" {titel!r:42s} sichtbar={s} {b}x{h}")
|
||||
|
||||
pruefe(not vorzeitig_weg,
|
||||
f"das Programm bleibt am Leben (Rueckgabe {p.returncode})"
|
||||
if vorzeitig_weg else "das Programm bleibt am Leben")
|
||||
if vorzeitig_weg and p.returncode == 0:
|
||||
print(" (Rueckgabe 0 heisst meist: es laeuft schon eine Fassung. "
|
||||
"Erst beenden, dann noch einmal.)")
|
||||
|
||||
pruefe(sichtbar, "ein sichtbares Programmfenster ist da")
|
||||
pruefe(any(str(t).startswith(stamm) and s for t, s, _, _ in gefunden),
|
||||
f"und es traegt den Programmnamen ({stamm})")
|
||||
|
||||
subprocess.run(["taskkill", "/PID", str(p.pid), "/T", "/F"], capture_output=True)
|
||||
try:
|
||||
p.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
|
||||
if fehler:
|
||||
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
|
||||
sys.exit(1)
|
||||
print("GRUEN | Die gebaute EXE zeigt ein Fenster")
|
||||
|
|
@ -70,6 +70,26 @@ from tkinter import messagebox # noqa: E402
|
|||
messagebox.showinfo = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: None
|
||||
|
||||
# Der Abfang MUSS vor dem ersten Fenster stehen: sonst geht ein echter Dialog
|
||||
# auf und der Lauf wartet auf einen Klick, den niemand macht. Genau das ist am
|
||||
# 06.09.2026 passiert - der Hinweis kam, bevor der Abfang gesetzt war.
|
||||
# Erprobungshinweis und Freigabe vor dem Beleg werden hier bejaht - geprueft
|
||||
# werden sie eigens in pruef_hinweis.py und pruef_freigabe.py des Rechnungstools.
|
||||
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
|
||||
"Storno wirklich": True, "Storno verwerfen": True}
|
||||
antworten["Erprobungsfassung"] = True
|
||||
antworten["jetzt erstellen?"] = True
|
||||
|
||||
|
||||
def askyesno(titel, text, **kw):
|
||||
for schlue, wert in antworten.items():
|
||||
if schlue in titel:
|
||||
return wert
|
||||
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
||||
|
||||
|
||||
messagebox.askyesno = askyesno
|
||||
|
||||
journal = JA.App()
|
||||
# Der Erprobungshinweis haengt an after(0) - ohne Ereignisschleife kaeme er nie
|
||||
# dran, und damit auch der verzoegerte Start nicht. update() treibt die Schleife
|
||||
|
|
@ -129,22 +149,6 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
|
|||
|
||||
import app as A # noqa: E402
|
||||
|
||||
# Erprobungshinweis und Freigabe vor dem Beleg werden hier bejaht - geprueft
|
||||
# werden sie eigens in pruef_hinweis.py und pruef_freigabe.py des Rechnungstools.
|
||||
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
|
||||
"Storno wirklich": True, "Storno verwerfen": True}
|
||||
antworten["Erprobungsfassung"] = True
|
||||
antworten["jetzt erstellen?"] = True
|
||||
|
||||
|
||||
def askyesno(titel, text, **kw):
|
||||
for schlue, wert in antworten.items():
|
||||
if schlue in titel:
|
||||
return wert
|
||||
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
||||
|
||||
|
||||
messagebox.askyesno = askyesno
|
||||
|
||||
tool = A.RechnungsApp()
|
||||
tool.update() # dasselbe fuer das Rechnungstool
|
||||
|
|
|
|||
33
updater.py
33
updater.py
|
|
@ -129,6 +129,22 @@ def summe_aus_release(release, exe_name):
|
|||
# ---------------------------------------------------------------------------
|
||||
# Laden und pruefen
|
||||
# ---------------------------------------------------------------------------
|
||||
def beschreibbar(ordner):
|
||||
"""Darf in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten.
|
||||
|
||||
Ein nicht beschreibbarer Ordner sieht von aussen genauso aus wie ein
|
||||
beschreibbarer - erst der Versuch zeigt es.
|
||||
"""
|
||||
probe = os.path.join(ordner, ".schreibprobe")
|
||||
try:
|
||||
with open(probe, "w", encoding="ascii") as f:
|
||||
f.write("x")
|
||||
os.remove(probe)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def sha256(pfad):
|
||||
h = hashlib.sha256()
|
||||
with open(pfad, "rb") as f:
|
||||
|
|
@ -309,6 +325,16 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
|
|||
sagen(f"{os.path.basename(ziel)} liegt schon da. Es wird gestartet.")
|
||||
return ziel
|
||||
|
||||
# Kann in den Installationsordner ueberhaupt geschrieben werden? Liegt die
|
||||
# Installation in einem geschuetzten Ordner (etwa "Programme"), schlaegt das
|
||||
# Ablegen fehl. Dann wird NICHT geladen - und vor allem: das Programm
|
||||
# startet trotzdem. Ein fehlgeschlagenes Update darf nie dazu fuehren, dass
|
||||
# gar nichts mehr geht.
|
||||
if not beschreibbar(ordner):
|
||||
sagen(f"In {ordner} darf nicht geschrieben werden - kein Update moeglich.")
|
||||
sagen("Es wird die vorhandene Fassung gestartet.")
|
||||
return hier_pfad
|
||||
|
||||
sagen(f"Neue Fassung {dort} gefunden (vorhanden: {hier}). Wird geladen ...")
|
||||
zwischen = tempfile.mkdtemp(prefix="update_")
|
||||
geladen = os.path.join(zwischen, exe_name)
|
||||
|
|
@ -332,7 +358,12 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
|
|||
return hier_pfad
|
||||
|
||||
# Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort.
|
||||
shutil.move(geladen, ziel)
|
||||
try:
|
||||
shutil.move(geladen, ziel)
|
||||
except OSError as e: # noqa: BLE001
|
||||
sagen(f"Die neue Fassung konnte nicht abgelegt werden ({e}).")
|
||||
sagen("Es wird die vorhandene Fassung gestartet.")
|
||||
return hier_pfad
|
||||
buch_schreiben(ordner, {"version": dort.lstrip("v"),
|
||||
"datei": os.path.basename(ziel),
|
||||
"sha256": erwartete_summe,
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier.
|
|||
Regel
|
||||
-----
|
||||
Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das
|
||||
"v": Tag `v1.7.0` -> VERSION "1.7.0". Wer das eine ohne das andere aendert,
|
||||
"v": Tag `v1.8.0` -> VERSION "1.8.0". Wer das eine ohne das andere aendert,
|
||||
baut dem Updater eine Falle.
|
||||
"""
|
||||
|
||||
VERSION = "1.7.0"
|
||||
VERSION = "1.8.0"
|
||||
|
||||
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
|
||||
REPOSITORY = "TheMockTv/Steuerrechnungstool"
|
||||
|
|
|
|||
106
wasserzeichen.py
Normal file
106
wasserzeichen.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Das Programmlogo - als Fenstersymbol und als blasses Wasserzeichen.
|
||||
|
||||
WOHER DAS BILD KOMMT
|
||||
--------------------
|
||||
Die Quelle ist die SVG aus dem Entwurf (`assets/programm_logo.svg`). Sie wird
|
||||
BEIM BAUEN einmal in 512 Pixel gerendert (`assets/programm_logo.png`) und in
|
||||
eine `programm.ico` fuer Windows umgesetzt. Hier zur Laufzeit wird nur noch
|
||||
skaliert.
|
||||
|
||||
Warum nicht direkt aus der SVG zeichnen? Weil dafuer `svglib` und `lxml` mit in
|
||||
die EXE muessten - und bis 512 Pixel ist das Ergebnis pixelgenau dasselbe. Wird
|
||||
das Wasserzeichen je groesser gebraucht, gehoert das umgestellt.
|
||||
|
||||
Warum ueberhaupt eine ICO? Weil Windows das Symbol einer EXE als Ressource in
|
||||
der Datei erwartet, und dieses Format ist ICO. Das gilt fuer jede Sprache, nicht
|
||||
nur fuer Python - eine SVG kann Windows dort nicht verwenden.
|
||||
|
||||
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
|
||||
hinueber.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tkinter as tk
|
||||
|
||||
_lager = {} # (fenster, groesse, deckkraft) -> PhotoImage
|
||||
|
||||
|
||||
def _ordner():
|
||||
"""Alle Orte, an denen die Bilder liegen koennen - Quelle wie EXE."""
|
||||
hier = os.path.dirname(os.path.abspath(__file__))
|
||||
aus_exe = getattr(sys, "_MEIPASS", "")
|
||||
neben_exe = os.path.dirname(os.path.abspath(sys.executable)) \
|
||||
if getattr(sys, "frozen", False) else ""
|
||||
return [p for p in (aus_exe, neben_exe, hier) if p]
|
||||
|
||||
|
||||
def pfad(name):
|
||||
"""Voller Pfad zu einer Datei in assets/ - oder ''."""
|
||||
for basis in _ordner():
|
||||
p = os.path.join(basis, "assets", name)
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
|
||||
def symbol_setzen(fenster, melden=print):
|
||||
"""Das Programmsymbol auf ein Fenster legen. True bei Erfolg.
|
||||
|
||||
Die EXE traegt es ohnehin - das Fenster aber nicht von selbst. Ohne das
|
||||
steht in der Titelleiste das nackte Tk-Symbol, obwohl die Datei ein eigenes
|
||||
hat.
|
||||
"""
|
||||
p = pfad("programm.ico")
|
||||
if not p:
|
||||
melden("[symbol] assets/programm.ico nicht gefunden")
|
||||
return False
|
||||
try:
|
||||
fenster.iconbitmap(default=p)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 - ohne Symbol laeuft es weiter
|
||||
melden(f"[symbol] konnte nicht gesetzt werden: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
|
||||
"""Das Logo blass als PhotoImage - oder None.
|
||||
|
||||
`deckkraft` 0..1, klein halten. Ein Wasserzeichen, das man LIEST, stoert
|
||||
beim Arbeiten; eines, das man ahnt, gehoert zum Programm.
|
||||
|
||||
`master` MUSS mitgegeben werden, wenn mehr als ein Tk-Fenster laeuft:
|
||||
sonst gehoert das Bild dem falschen Fenster und Tk meldet spaeter, es
|
||||
kenne es nicht.
|
||||
"""
|
||||
fenster = master if master is not None else tk._default_root
|
||||
if fenster is None:
|
||||
return None
|
||||
schluessel = (id(fenster), groesse, round(deckkraft, 3))
|
||||
if schluessel in _lager:
|
||||
return _lager[schluessel]
|
||||
p = pfad("programm_logo.png")
|
||||
if not p:
|
||||
melden("[wasserzeichen] assets/programm_logo.png nicht gefunden")
|
||||
return None
|
||||
try:
|
||||
from PIL import Image, ImageTk
|
||||
bild = Image.open(p).convert("RGBA").resize((groesse, groesse), Image.LANCZOS)
|
||||
bild.putalpha(bild.getchannel("A").point(lambda w: int(w * deckkraft)))
|
||||
fertig = ImageTk.PhotoImage(bild, master=fenster)
|
||||
except Exception as e: # noqa: BLE001 - ein Wasserzeichen darf nichts blockieren
|
||||
melden(f"[wasserzeichen] nicht ladbar: {e}")
|
||||
return None
|
||||
_lager[schluessel] = fertig
|
||||
return fertig
|
||||
|
||||
|
||||
def kopf(hoehe=28, master=None, melden=print):
|
||||
"""Das Logo fuer die Kopfzeile - volle Deckkraft, kleine Hoehe.
|
||||
|
||||
Getrennt von hole(): dort geht es um ein blasses Wasserzeichen, hier um ein
|
||||
Erkennungszeichen. Zwei Zwecke, zwei Funktionen - sonst haette eine davon
|
||||
einen Schalter, den man beim Lesen nachschlagen muss.
|
||||
"""
|
||||
return hole(hoehe, 1.0, master=master, melden=melden)
|
||||
Loading…
Add table
Add a link
Reference in a new issue