diff --git a/.gitignore b/.gitignore index 6da1502..55a4fe8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ version_info.txt # Virtual environments .venv + +# WiX Installer Build-Artefakte +ProductFiles.wxs +*.msi +*.wixpdb +.wix/ diff --git a/DocuMentor.wxs b/DocuMentor.wxs new file mode 100644 index 0000000..0987872 --- /dev/null +++ b/DocuMentor.wxs @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build_msi.py b/build_msi.py new file mode 100644 index 0000000..3183eed --- /dev/null +++ b/build_msi.py @@ -0,0 +1,53 @@ +""" +WiX MSI Build-Skript für DocuMentor (WiX v6) + +Erstellt einen MSI-Installer aus dem PyInstaller Build. +""" + +import subprocess +import sys +from pathlib import Path + + +def build_msi(): + """Erstellt MSI-Installer mit WiX v6.""" + project_root = Path(__file__).parent + dist_dir = project_root / "dist" / "DocuMentor" + + if not dist_dir.exists(): + print("FEHLER: PyInstaller Build nicht gefunden!") + print("Bitte zuerst ausführen: uv run python build_windows.py") + sys.exit(1) + + print(f"DocuMentor Build gefunden: {dist_dir}") + print() + + # Schritt 1: ProductFiles.wxs generieren (ersetzt WiX heat) + print("Schritt 1/2: Generiere ProductFiles.wxs...") + result = subprocess.run(["uv", "run", "python", "generate_wix_files.py"], check=False) + + if result.returncode != 0: + print("\nFEHLER: ProductFiles.wxs Generierung fehlgeschlagen!") + sys.exit(1) + + print() + + # Schritt 2: MSI kompilieren mit WiX v6 + print("Schritt 2/2: Kompiliere MSI-Installer...") + result = subprocess.run(["wix", "build", "DocuMentor.wxs", "ProductFiles.wxs", "-o", "DocuMentor.msi"], check=False) + + if result.returncode != 0: + print("\nFEHLER: MSI-Kompilierung fehlgeschlagen!") + print("Stelle sicher, dass WiX v6 installiert ist:") + print(" dotnet tool install --global wix") + sys.exit(1) + + print() + print("[OK] MSI erfolgreich erstellt: DocuMentor.msi") + print() + print("Installation testen mit:") + print(" msiexec /i DocuMentor.msi") + + +if __name__ == "__main__": + build_msi() diff --git a/docs/windows_distribution.md b/docs/windows_distribution.md index c0e0cb5..e07666c 100644 --- a/docs/windows_distribution.md +++ b/docs/windows_distribution.md @@ -51,10 +51,8 @@ Dies erstellt: **Voraussetzungen:** ```bash -# WiX Toolset v4 installieren (empfohlen) +# WiX Toolset v6 installieren (aktuell) dotnet tool install --global wix - -# Oder WiX v3 von https://wixtoolset.org/releases/ ``` **Schritt 1: PyInstaller Build erstellen** @@ -62,166 +60,33 @@ dotnet tool install --global wix uv run python build_windows.py ``` -**Schritt 2: WiX-Konfiguration erstellen (`DocuMentor.wxs`):** -```xml - - - - - - - +**Schritt 2: ProductFiles.wxs generieren** - - - - +**WICHTIG**: WiX v6 hat das `heat` Tool entfernt. Stattdessen verwenden wir ein Python-Skript: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -**Schritt 3: GUIDs generieren** ```bash -# GUIDs mit PowerShell generieren -powershell -Command "[guid]::NewGuid()" - -# Oder Python-Skript verwenden -uv run python generate_guid.py +# Generiert automatisch ProductFiles.wxs mit allen Dateien aus dist/DocuMentor +uv run python generate_wix_files.py ``` -**Schritt 4: Dateien automatisch harvesten (empfohlen)** +**Schritt 3: MSI kompilieren** ```bash -# WiX v3: Heat Tool verwenden -heat dir dist\DocuMentor -cg ProductComponents -gg -sfrag -srd -dr INSTALLFOLDER -out ProductFiles.wxs - -# WiX v4: -wix heat dir dist\DocuMentor -cg ProductComponents -gg -sfrag -srd -dr INSTALLFOLDER -out ProductFiles.wxs +# WiX v6 Build +wix build DocuMentor.wxs ProductFiles.wxs -o DocuMentor.msi ``` -**Schritt 5: MSI kompilieren** -```bash -# WiX v3 -candle DocuMentor.wxs -out obj\ -light obj\DocuMentor.wixobj -out dist\installer\DocuMentor-0.1.0.msi -ext WixUIExtension +**Hinweis**: Die Dateien `DocuMentor.wxs` und `generate_wix_files.py` sind bereits im Repository vorhanden und WiX v4/v6-kompatibel. -# WiX v4 -wix build DocuMentor.wxs -out dist\installer\DocuMentor-0.1.0.msi -``` - -**Schritt 6: MSI testen** +**Schritt 4: MSI testen** ```bash # Installation (als Administrator) -msiexec /i dist\installer\DocuMentor-0.1.0.msi +msiexec /i DocuMentor.msi # Silent Installation für Deployment -msiexec /i DocuMentor-0.1.0.msi /quiet /qn /norestart +msiexec /i DocuMentor.msi /quiet /qn /norestart # Deinstallation -msiexec /x dist\installer\DocuMentor-0.1.0.msi +msiexec /x DocuMentor.msi ``` **MSI-Vorteile gegenüber Inno Setup:** @@ -234,7 +99,7 @@ msiexec /x dist\installer\DocuMentor-0.1.0.msi **Build-Automatisierung (`build_msi.py`):** ```python -"""WiX MSI Build-Skript für DocuMentor""" +"""WiX MSI Build-Skript für DocuMentor (WiX v6)""" import subprocess import sys from pathlib import Path @@ -247,35 +112,22 @@ def build_msi(): print("Fehler: PyInstaller Build nicht gefunden. Erst build_windows.py ausführen!") sys.exit(1) - # Heat: Dateien harvesten - print("Harvesting Dateien mit Heat...") + # Schritt 1: ProductFiles.wxs generieren (ersetzt heat) + print("Generiere ProductFiles.wxs...") subprocess.run([ - "heat", "dir", str(dist_dir), - "-cg", "ProductComponents", - "-gg", "-sfrag", "-srd", - "-dr", "INSTALLFOLDER", - "-out", "ProductFiles.wxs" + "uv", "run", "python", "generate_wix_files.py" ], check=True) - # Candle: WXS zu WIXOBJ kompilieren - print("Kompiliere WXS-Dateien...") + # Schritt 2: MSI kompilieren mit WiX v6 + print("Kompiliere MSI-Installer...") subprocess.run([ - "candle", "DocuMentor.wxs", "ProductFiles.wxs", - "-out", "obj\\" + "wix", "build", + "DocuMentor.wxs", + "ProductFiles.wxs", + "-o", "DocuMentor.msi" ], check=True) - # Light: WIXOBJ zu MSI linken - print("Erzeuge MSI-Installer...") - (project_root / "dist" / "installer").mkdir(exist_ok=True) - subprocess.run([ - "light", - "obj\\DocuMentor.wixobj", - "obj\\ProductFiles.wixobj", - "-out", "dist\\installer\\DocuMentor-0.1.0.msi", - "-ext", "WixUIExtension" - ], check=True) - - print("\n✅ MSI erstellt: dist\\installer\\DocuMentor-0.1.0.msi") + print("\n[OK] MSI erstellt: DocuMentor.msi") if __name__ == "__main__": build_msi() diff --git a/generate_wix_files.py b/generate_wix_files.py new file mode 100644 index 0000000..4b60858 --- /dev/null +++ b/generate_wix_files.py @@ -0,0 +1,184 @@ +""" +WiX ProductFiles.wxs Generator + +Generiert automatisch eine WXS-Datei mit allen Dateien aus dist/DocuMentor. +Ersetzt die veraltete 'wix heat' Funktionalität für WiX v6. +""" + +import uuid +from pathlib import Path +from xml.etree import ElementTree as ET + + +def generate_guid() -> str: + """Generiert eine neue GUID.""" + return str(uuid.uuid4()).upper() + + +def sanitize_id(name: str) -> str: + """ + Macht einen String WiX-konform für IDs. + + WiX erlaubt nur: A-Z, a-z, 0-9, _, . + Darf nicht mit Zahl beginnen. + """ + # Ersetze illegale Zeichen + sanitized = name.replace("-", "_").replace(" ", "_").replace(".", "_") + + # Entferne alle anderen nicht erlaubten Zeichen + allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_" + sanitized = "".join(c if c in allowed else "_" for c in sanitized) + + # Darf nicht mit Zahl beginnen + if sanitized and sanitized[0].isdigit(): + sanitized = f"_{sanitized}" + + return sanitized + + +def create_wix_fragment(dist_dir: Path) -> ET.Element: + """ + Erstellt ein WiX Fragment mit allen Dateien aus dem dist Verzeichnis. + + Args: + dist_dir: Pfad zum dist/DocuMentor Verzeichnis + + Returns: + ET.Element: Wix Root-Element mit Fragment + """ + # Namespace (WiX v4+) + ns = "http://wixtoolset.org/schemas/v4/wxs" + ET.register_namespace("", ns) + + # Root Element + wix = ET.Element(f"{{{ns}}}Wix") + fragment = ET.SubElement(wix, f"{{{ns}}}Fragment") + component_group = ET.SubElement( + fragment, f"{{{ns}}}ComponentGroup", {"Id": "ProductComponents", "Directory": "INSTALLFOLDER"} + ) + + # Sammle alle Dateien + all_files = sorted(dist_dir.rglob("*")) + files_only = [f for f in all_files if f.is_file()] + + print(f"Gefunden: {len(files_only)} Dateien") + + # Gruppiere nach Verzeichnis + dirs_dict: dict[Path, list[Path]] = {} + for file in files_only: + rel_dir = file.parent.relative_to(dist_dir) + if rel_dir not in dirs_dict: + dirs_dict[rel_dir] = [] + dirs_dict[rel_dir].append(file) + + # Erstelle Directory Fragments + dir_fragment = ET.SubElement(wix, f"{{{ns}}}Fragment") + + # INSTALLFOLDER ist bereits in DocuMentor.wxs definiert + directory_ref = ET.SubElement(dir_fragment, f"{{{ns}}}DirectoryRef", {"Id": "INSTALLFOLDER"}) + + # Erstelle Verzeichnisstruktur + created_dirs = {"INSTALLFOLDER": directory_ref} + + for rel_dir in sorted(dirs_dict.keys()): + if rel_dir == Path("."): + continue + + parts = rel_dir.parts + parent_id = "INSTALLFOLDER" + + for i, part in enumerate(parts): + current_path = Path(*parts[: i + 1]) + dir_id = f"Dir_{sanitize_id(current_path.as_posix().replace('/', '_'))}" + + if dir_id not in created_dirs: + parent_elem = created_dirs[parent_id] + new_dir = ET.SubElement(parent_elem, f"{{{ns}}}Directory", {"Id": dir_id, "Name": part}) + created_dirs[dir_id] = new_dir + parent_id = dir_id + else: + parent_id = dir_id + + # Füge Komponenten hinzu + component_counter = 0 + + for rel_dir, files in sorted(dirs_dict.items()): + if rel_dir == Path("."): + dir_id = "INSTALLFOLDER" + else: + dir_id = f"Dir_{sanitize_id(rel_dir.as_posix().replace('/', '_'))}" + + # Erstelle eine Komponente pro Verzeichnis + component_id = f"Component_{sanitize_id(dir_id)}" + component_counter += 1 + + component = ET.SubElement( + component_group, f"{{{ns}}}Component", {"Id": component_id, "Directory": dir_id, "Guid": generate_guid()} + ) + + # Füge alle Dateien des Verzeichnisses hinzu + for idx, file in enumerate(files): + file_id = f"File_{sanitize_id(component_id)}_{idx}" + # Absoluter Pfad für WiX + source_path = str(file).replace("/", "\\") + + file_attribs = {"Id": file_id, "Source": source_path, "Name": file.name} + + # Erste Datei ist KeyPath + if idx == 0: + file_attribs["KeyPath"] = "yes" + + ET.SubElement(component, f"{{{ns}}}File", file_attribs) + + print(f"Erstellt: {component_counter} Komponenten") + + return wix + + +def format_xml(element: ET.Element, level: int = 0) -> None: + """Formatiert XML mit Einrückungen (in-place).""" + indent = " " + i = f"\n{indent * level}" + + if len(element): + if not element.text or not element.text.strip(): + element.text = i + indent + if not element.tail or not element.tail.strip(): + element.tail = i + last_child = None + for child in element: + format_xml(child, level + 1) + last_child = child + if last_child is not None and (not last_child.tail or not last_child.tail.strip()): + last_child.tail = i + else: + if level and (not element.tail or not element.tail.strip()): + element.tail = i + + +def main(): + """Hauptfunktion.""" + dist_dir = Path("dist/DocuMentor") + output_file = Path("ProductFiles.wxs") + + if not dist_dir.exists(): + print(f"FEHLER: {dist_dir} existiert nicht!") + print("Führe zuerst 'uv run pyinstaller DocuMentor.spec' aus.") + return + + print(f"Generiere ProductFiles.wxs aus {dist_dir}...") + + wix_root = create_wix_fragment(dist_dir) + format_xml(wix_root) + + # Schreibe XML + tree = ET.ElementTree(wix_root) + tree.write(output_file, encoding="utf-8", xml_declaration=True) + + print(f"[OK] {output_file} erfolgreich erstellt!") + print(f"\nNaechste Schritte:") + print(f"1. wix build DocuMentor.wxs ProductFiles.wxs -o DocuMentor.msi") + + +if __name__ == "__main__": + main()