Windows-Support, Build-Skripte und Installer fuer PulseGate Desktop

- Python-App cross-platform: %APPDATA%-Konfiguration, Terminal-Erkennung
  (wt/cmd/powershell), Windows-kompatibler SSH-Start, Segoe-UI-Fonts
- App-Icon (ICO/PNG) mit Skript ohne externe Abhaengigkeiten
- PyInstaller-Build (build_windows.bat) und Inno-Setup-Installer
- Gitea-Actions-Workflow fuer automatischen Windows-Build
- requirements.txt, .gitignore, README aktualisiert; __pycache__ entfernt
This commit is contained in:
2026-09-06 22:28:55 +00:00
parent dcafc1e7c1
commit a515c32385
12 changed files with 392 additions and 63 deletions
+54
View File
@@ -0,0 +1,54 @@
name: Build Windows
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-build.txt
python scripts\make_icon.py
- name: Build EXE (PyInstaller)
run: |
python -m PyInstaller --noconfirm --clean --onefile --windowed --name PulseGate --icon assets\pulsegate.ico desktop\pulsegate_desktop.py
Get-Item dist\PulseGate.exe | Select-Object FullName, Length
- name: Install Inno Setup
shell: bash
run: |
choco install innosetup --no-progress --yes
- name: Build installer
run: iscc installer\installer.iss
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: PulseGate-Windows
path: |
dist/PulseGate.exe
installer/output/*.exe
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: gitea/actions/release-action@v1
with:
files: |
dist/PulseGate.exe
installer/output/*.exe
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.spec
build/
dist/
installer/output/
.venv/
venv/
.env
+57 -25
View File
@@ -1,6 +1,8 @@
# PulseGate Desktop
PulseGate Desktop ist eine grafische Desktop-Variante des PulseGate SSH Managers. Die App verwaltet SSH-Server, nutzt dieselbe YAML-Konfiguration wie die bestehende TUI und startet Verbindungen in einem lokalen Terminal-Emulator.
PulseGate Desktop ist eine grafische Desktop-App zur Verwaltung von SSH-Servern. Die App nutzt eine YAML-Konfiguration, listet und verwaltet Server, zeigt SSH-Befehle an und startet Verbindungen im lokalen Terminal-Emulator.
Läuft nativ auf **Linux** und **Windows**. Für Windows liegt ein fertiger Installer-Build (PyInstaller + Inno Setup) bei.
## Features
@@ -15,29 +17,35 @@ PulseGate Desktop ist eine grafische Desktop-Variante des PulseGate SSH Managers
## Voraussetzungen
- Linux Desktop-Umgebung
### Linux
- Python 3 mit `tkinter`
- Python-Paket `PyYAML`
- `ssh`
- ein unterstützter Terminal-Emulator:
- `kitty`
- `alacritty`
- `konsole`
- `gnome-terminal`
- `xfce4-terminal`
- `xterm`
- `kitty`, `alacritty`, `konsole`, `gnome-terminal`, `xfce4-terminal` oder `xterm`
Für die optionale Web-Variante wird zusätzlich Go benötigt.
### Windows
- Windows 10/11 (OpenSSH-Client ist standardmäßig integriert)
- Optionale Terminal-Emulatoren (werden in dieser Reihenfolge erkannt):
- `wt` (Windows Terminal)
- `cmd` (Fallback, immer verfügbar)
- `powershell`
- Für den Installer-Build zusätzlich: Python 3 + Inno Setup 6
## Start
Desktop-App starten:
```bash
cd /home/pascal/Projekte/PulseGate-GUI
./pulsegate-desktop
```
Direkt mit Python:
```bash
python3 desktop/pulsegate_desktop.py
```
Optional kann ein eigener Config-Pfad übergeben werden:
```bash
@@ -48,9 +56,8 @@ Optional kann ein eigener Config-Pfad übergeben werden:
Standardmäßig liest und schreibt PulseGate Desktop diese Datei:
```text
~/.config/pulsegate/config.yaml
```
- Linux: `~/.config/pulsegate/config.yaml`
- Windows: `%APPDATA%\pulsegate\config.yaml`
Beispiel:
@@ -77,31 +84,56 @@ quick_commands:
command: df -h
```
## Windows-Build und Installer
Auf einem Windows-Rechner:
1. Build der EXE (erzeugt `dist\PulseGate.exe`):
```bat
build_windows.bat
```
2. Installer kompilieren – Inno Setup 6 öffnen mit:
```text
installer\installer.iss
```
oder per Kommandozeile:
```bat
iscc installer\installer.iss
```
Ergebnis: `installer\output\PulseGate-Setup-1.0.0.exe`
Alternativ wird bei einem Tag `v*` (z. B. `git tag v1.0.0 && git push --tags`) automatisch per Gitea Actions ein Windows-Build inklusive Installer erstellt.
## Web-Variante
Im Projekt liegt zusätzlich noch eine einfache lokale Web-Variante:
Im Projekt liegt zusätzlich eine optionale lokale Web-Variante:
```bash
go run ./cmd/pulsegate-gui
```
Danach öffnen:
```text
http://127.0.0.1:8090
```
Danach öffnen: `http://127.0.0.1:8090`
## Projektstruktur
```text
.
├── cmd/pulsegate-gui/ # optionaler Go-Webserver
├── desktop/ # Python/Tk Desktop-App
├── internal/config/ # Go-Konfigurationslogik
├── internal/models/ # Go-Datenmodelle
├── cmd/pulsegate-gui/ # optionale Go-Webvariante
├── desktop/ # Python/Tk Desktop-App (Linux + Windows)
├── installer/ # Inno Setup Installer-Skript
├── assets/ # App-Icon
├── scripts/ # Icon-Generierung
├── web/ # optionale Web-Oberfläche
├── go.mod
├── pulsegate-desktop # Launcher für die Desktop-App
├── build_windows.bat # Windows-EXE-Build (PyInstaller)
├── requirements.txt # Laufzeit-Abhängigkeiten
├── requirements-build.txt # Build-Abhängigkeiten
├── pulsegate-desktop # Linux-Launcher
└── README.md
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+40
View File
@@ -0,0 +1,40 @@
@echo off
setlocal
REM ============================================================
REM Build PulseGate Desktop als Windows-EXE (PyInstaller)
REM Inno Setup-Build danach: installer\installer.iss oeffnen
REM bzw. als Script-Kompilierung ausfuehren.
REM ============================================================
set "PY=%PYTHON%"
if "%PY%"=="" set "PY=py -3"
if "%PY%"=="" set "PY=python"
echo [1/3] Installiere Build-Abhaengigkeiten ...
%PY% -m pip install --upgrade pip
%PY% -m pip install -r requirements-build.txt
if errorlevel 1 goto :error
echo [2/3] Generiere Icon-Assets ...
%PY% scripts\make_icon.py
if errorlevel 1 goto :error
echo [3/3] Baue EXE ...
%PY% -m PyInstaller ^
--noconfirm --clean ^
--onefile ^
--windowed ^
--name PulseGate ^
--icon assets\pulsegate.ico ^
desktop\pulsegate_desktop.py
if errorlevel 1 goto :error
echo.
echo Fertig! EXE liegt unter dist\PulseGate.exe
echo Installer: installer\installer.iss mit Inno Setup kompilieren.
exit /b 0
:error
echo.
echo Build fehlgeschlagen!
exit /b 1
+83 -36
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
@@ -14,6 +15,8 @@ import yaml
APP_TITLE = "PulseGate Desktop"
APP_VERSION = "1.0.0"
IS_WINDOWS = sys.platform == "win32"
COLORS = {
"bg": "#07110d",
@@ -30,16 +33,25 @@ COLORS = {
"danger": "#ff6666",
}
FONT = ("Inter", 11)
FONT_SMALL = ("Inter", 9)
FONT_TITLE = ("Inter", 24, "bold")
FONT_CARD = ("Inter", 13, "bold")
UI_FONT = ("Segoe UI", 11) if IS_WINDOWS else ("Inter", 11)
UI_FONT_SMALL = ("Segoe UI", 9) if IS_WINDOWS else ("Inter", 9)
UI_FONT_MONO = ("Consolas", 10) if IS_WINDOWS else ("JetBrains Mono", 10)
FONT = UI_FONT
FONT_SMALL = UI_FONT_SMALL
FONT_TITLE = (UI_FONT[0], 24, "bold")
FONT_CARD = (UI_FONT[0], 13, "bold")
def config_path() -> Path:
if len(sys.argv) > 1:
return Path(sys.argv[1]).expanduser()
if IS_WINDOWS:
appdata = os.environ.get("APPDATA")
if appdata:
return Path(appdata) / "pulsegate" / "config.yaml"
return Path.home() / "AppData" / "Roaming" / "pulsegate" / "config.yaml"
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home) / "pulsegate" / "config.yaml"
@@ -115,11 +127,20 @@ def save_config(path: Path, config: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(normalize_config(config), handle, sort_keys=False, allow_unicode=True)
path.chmod(0o600)
try:
path.chmod(0o600)
except OSError:
pass
def expand_home(value: str) -> str:
return str(Path(value).expanduser()) if value.startswith("~/") else value
if not (value.startswith("~/") or value.startswith("~\\")):
return value
if IS_WINDOWS:
profile = os.environ.get("USERPROFILE")
if profile:
return os.path.join(profile, value[2:].replace("/", "\\"))
return str(Path(value).expanduser())
def ssh_args(server: dict) -> list[str]:
@@ -130,7 +151,21 @@ def ssh_args(server: dict) -> list[str]:
return args
def display_command(server: dict) -> str:
args = ssh_args(server)
if IS_WINDOWS:
return subprocess.list2cmdline(args)
return " ".join(shlex.quote(arg) for arg in args)
def detect_terminal() -> str | None:
if IS_WINDOWS:
for name in ("wt", "cmd", "powershell"):
path = shutil.which(name)
if path:
return path
return None
for name in ("kitty", "alacritty", "konsole", "gnome-terminal", "xfce4-terminal", "xterm"):
path = shutil.which(name)
if path:
@@ -140,24 +175,35 @@ def detect_terminal() -> str | None:
def terminal_command(terminal: str, server: dict) -> list[str]:
title = f"PulseGate - {server.get('name') or server.get('host')}"
command = " ".join(shlex.quote(arg) for arg in ssh_args(server))
hold_command = f"{command}; printf '\\nSSH beendet. Enter zum Schliessen...'; read _"
if IS_WINDOWS:
params = subprocess.list2cmdline(ssh_args(server))
stem = Path(terminal).stem.lower()
if stem in ("powershell", "pwsh"):
hold = f"& {params}; Write-Host ''; Write-Host 'SSH beendet.'; Read-Host 'Enter zum Schliessen...'"
return [terminal, "-NoExit", "-Command", hold]
hold = f"{params} & echo. & echo SSH beendet. Bitte Fenster schliessen... & pause"
if stem == "wt":
return [terminal, "new-tab", "--title", title, "cmd.exe", "/k", hold]
return [terminal, "/k", hold]
hold = " ".join(shlex.quote(arg) for arg in ssh_args(server)) + "; printf '\\nSSH beendet. Enter zum Schließen...'; read _"
name = Path(terminal).name
if name == "kitty":
return [terminal, "--title", title, "sh", "-lc", hold_command]
return [terminal, "--title", title, "sh", "-lc", hold]
if name == "alacritty":
return [terminal, "--title", title, "-e", "sh", "-lc", hold_command]
return [terminal, "--title", title, "-e", "sh", "-lc", hold]
if name == "konsole":
return [terminal, "--new-tab", "-p", f"tabtitle={title}", "-e", "sh", "-lc", hold_command]
return [terminal, "--new-tab", "-p", f"tabtitle={title}", "-e", "sh", "-lc", hold]
if name == "gnome-terminal":
return [terminal, "--title", title, "--", "sh", "-lc", hold_command]
return [terminal, "--title", title, "--", "sh", "-lc", hold]
if name == "xfce4-terminal":
return [terminal, "--title", title, "--command", f"sh -lc {shlex.quote(hold_command)}"]
return [terminal, "--title", title, "--command", f"sh -lc {shlex.quote(hold)}"]
if name == "xterm":
return [terminal, "-T", title, "-e", "sh", "-lc", hold_command]
return [terminal, "-T", title, "-e", "sh", "-lc", hold]
return [terminal, "-e", "sh", "-lc", hold_command]
return [terminal, "-e", "sh", "-lc", hold]
class PulseGateDesktop(tk.Tk):
@@ -204,7 +250,7 @@ class PulseGateDesktop(tk.Tk):
text="PG",
bg=COLORS["accent"],
fg="#001a10",
font=("Inter", 14, "bold"),
font=(UI_FONT[0], 14, "bold"),
width=4,
height=2,
)
@@ -212,7 +258,7 @@ class PulseGateDesktop(tk.Tk):
brand_text = tk.Frame(brand, bg=COLORS["sidebar"])
brand_text.pack(side="left", padx=12)
tk.Label(brand_text, text="PulseGate", bg=COLORS["sidebar"], fg=COLORS["text"], font=("Inter", 18, "bold")).pack(anchor="w")
tk.Label(brand_text, text="PulseGate", bg=COLORS["sidebar"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w")
tk.Label(brand_text, text="Desktop SSH", bg=COLORS["sidebar"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w")
self._label(sidebar, "Suche", bg=COLORS["sidebar"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", padx=22)
@@ -249,7 +295,7 @@ class PulseGateDesktop(tk.Tk):
self.title_label.pack(anchor="w")
self._button(topbar, "Neu laden", self._reload, variant="ghost").pack(side="right", padx=(8, 0))
self._button(topbar, "Server hinzufuegen", self._add_server).pack(side="right")
self._button(topbar, "Server hinzufügen", self._add_server).pack(side="right")
self.page_host = tk.Frame(content, bg=COLORS["bg"])
self.page_host.pack(fill="both", expand=True)
@@ -282,7 +328,7 @@ class PulseGateDesktop(tk.Tk):
header = tk.Frame(editor_outer, bg=COLORS["surface"])
header.pack(fill="x", pady=(0, 16))
self.form_title = self._label(header, "Server bearbeiten", bg=COLORS["surface"], fg=COLORS["text"], font=("Inter", 18, "bold"))
self.form_title = self._label(header, "Server bearbeiten", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold"))
self.form_title.pack(side="left")
self.auth_chip = self._chip(header, "key", COLORS["accent_2"])
self.auth_chip.pack(side="right")
@@ -331,7 +377,7 @@ class PulseGateDesktop(tk.Tk):
self.vars["kitty_fix"] = tk.BooleanVar(value=True)
tk.Checkbutton(
editor_outer,
text="Kitty Fix fuer diesen Server nutzen",
text="Kitty Fix für diesen Server nutzen",
variable=self.vars["kitty_fix"],
bg=COLORS["surface"],
fg=COLORS["text"],
@@ -345,7 +391,7 @@ class PulseGateDesktop(tk.Tk):
actions.pack(fill="x", pady=(16, 14))
self._button(actions, "Verbinden", self._connect).pack(side="left")
self._button(actions, "SSH Befehl", self._show_command, variant="ghost").pack(side="left", padx=(8, 0))
self._button(actions, "Loeschen", self._delete_current_server, variant="danger").pack(side="right")
self._button(actions, "Löschen", self._delete_current_server, variant="danger").pack(side="right")
self._button(actions, "Speichern", self._save_current_server).pack(side="right", padx=(0, 8))
self.command_text = tk.Text(
@@ -356,7 +402,7 @@ class PulseGateDesktop(tk.Tk):
insertbackground=COLORS["text"],
relief="flat",
wrap="word",
font=("JetBrains Mono", 10),
font=UI_FONT_MONO,
padx=12,
pady=10,
)
@@ -370,7 +416,7 @@ class PulseGateDesktop(tk.Tk):
panel = self._card(page, bg=COLORS["surface"], padx=22, pady=22)
panel.pack(anchor="nw", fill="x")
self._label(panel, "Terminal", bg=COLORS["surface"], fg=COLORS["text"], font=("Inter", 18, "bold")).pack(anchor="w")
self._label(panel, "Terminal", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w")
self._label(panel, "Diese Werte werden beim Starten einer SSH-Verbindung genutzt.", bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", pady=(4, 18))
self.settings_vars["theme"] = tk.StringVar()
@@ -461,7 +507,7 @@ class PulseGateDesktop(tk.Tk):
card = self._card(self.commands_host, bg=COLORS["surface"], padx=16, pady=14)
card.pack(fill="x", pady=(0, 10))
self._label(card, command.get("name") or "Command", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD).pack(anchor="w")
self._label(card, command.get("command") or "", bg=COLORS["surface"], fg=COLORS["accent"], font=("JetBrains Mono", 10)).pack(anchor="w", pady=(7, 0))
self._label(card, command.get("command") or "", bg=COLORS["surface"], fg=COLORS["accent"], font=UI_FONT_MONO).pack(anchor="w", pady=(7, 0))
def _refresh_settings(self) -> None:
settings = self.config_data["settings"]
@@ -567,12 +613,12 @@ class PulseGateDesktop(tk.Tk):
if not self.config_data["servers"]:
return
server = self.config_data["servers"][self.selected_index]
if not messagebox.askyesno(APP_TITLE, f"{server.get('name')} wirklich loeschen?"):
if not messagebox.askyesno(APP_TITLE, f"{server.get('name')} wirklich löschen?"):
return
del self.config_data["servers"][self.selected_index]
self.selected_index = max(0, self.selected_index - 1)
save_config(self.path, self.config_data)
self.status_var.set("Server geloescht")
self.status_var.set("Server gelöscht")
self._refresh_all()
def _save_settings(self) -> None:
@@ -594,7 +640,7 @@ class PulseGateDesktop(tk.Tk):
def _current_server(self) -> dict | None:
if not self.config_data["servers"]:
messagebox.showerror(APP_TITLE, "Kein Server ausgewaehlt.")
messagebox.showerror(APP_TITLE, "Kein Server ausgewählt.")
return None
return self.config_data["servers"][self.selected_index]
@@ -603,21 +649,22 @@ class PulseGateDesktop(tk.Tk):
if not server:
return
self.command_text.delete("1.0", "end")
self.command_text.insert("1.0", " ".join(shlex.quote(arg) for arg in ssh_args(server)))
self.command_text.insert("1.0", display_command(server))
def _connect(self) -> None:
server = self._current_server()
if not server:
return
if not self.terminal:
messagebox.showerror(APP_TITLE, "Kein unterstuetzter Terminal-Emulator gefunden.")
messagebox.showerror(APP_TITLE, "Kein unterstützter Terminal-Emulator gefunden.")
return
env = os.environ.copy()
settings = self.config_data["settings"]
terminal_settings = settings.get("terminal", {})
if terminal_settings.get("enable_kitty_fix", True) and server.get("kitty_fix", True):
env["TERM"] = terminal_settings.get("term") or "xterm-256color"
if not IS_WINDOWS:
settings = self.config_data["settings"]
terminal_settings = settings.get("terminal", {})
if terminal_settings.get("enable_kitty_fix", True) and server.get("kitty_fix", True):
env["TERM"] = terminal_settings.get("term") or "xterm-256color"
try:
subprocess.Popen(terminal_command(self.terminal, server), env=env)
@@ -674,7 +721,7 @@ class PulseGateDesktop(tk.Tk):
bd=0,
padx=14,
pady=9,
font=("Inter", 10, "bold"),
font=(UI_FONT[0], 10, "bold"),
cursor="hand2",
)
@@ -692,7 +739,7 @@ class PulseGateDesktop(tk.Tk):
padx=12,
pady=10,
anchor="w",
font=("Inter", 10, "bold"),
font=(UI_FONT[0], 10, "bold"),
cursor="hand2",
highlightthickness=1,
highlightbackground=COLORS["sidebar"],
+50
View File
@@ -0,0 +1,50 @@
; Inno Setup Script fuer PulseGate Desktop
; Kompilieren: iscc installer\installer.iss (Inno Setup 6)
#define MyAppName "PulseGate Desktop"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "Pepe44DEV"
#define MyAppURL "https://git.pepe44.dev/Pepe44DEV/PulseGate-GUI"
#define MyAppExeName "PulseGate.exe"
#define MyAppAssocName MyAppName + " Config"
[Setup]
AppId={{8AF1A0C9-4B5D-4E11-9C0A-3B2E7F4D1A55}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\PulseGate
DefaultGroupName=PulseGate
DisableProgramGroupPage=yes
OutputDir=output
OutputBaseFilename=PulseGate-Setup-{#MyAppVersion}
SetupIconFile=..\assets\pulsegate.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequiredOverridesAllowed=dialog
AllowNoIcons=yes
[Languages]
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{#MyAppName} Konfiguration oeffnen"; Filename: "%APPDATA%\pulsegate\config.yaml"; Flags: runminimized; Comment: "PulseGate-Konfigurationsdatei"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+2
View File
@@ -0,0 +1,2 @@
PyYAML>=6.0
pyinstaller>=6.0
+1
View File
@@ -0,0 +1 @@
PyYAML>=6.0
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Generiert das PulseGate-Icon als .ico (und .png) ohne externe Abhängigkeiten."""
from __future__ import annotations
import struct
import sys
import zlib
from pathlib import Path
ACCENT = (0x00, 0xFF, 0x99)
SURFACE = (0x0D, 0x1B, 0x15)
BG = (0x07, 0x11, 0x0D)
ROOT = Path(__file__).resolve().parent.parent
OUT_DIR = ROOT / "assets"
SIZES = [16, 32, 48, 64, 128, 256]
def pixel(size: int, x: int, y: int) -> tuple[int, int, int, int]:
nx = (x + 0.5) / size
ny = (y + 0.5) / size
if nx < 0.08 or nx > 0.92 or ny < 0.08 or ny > 0.92:
return (*ACCENT, 255)
if 0.34 < nx < 0.66 and 0.34 < ny < 0.66:
return (*ACCENT, 255)
if 0.24 < nx < 0.76 and 0.24 < ny < 0.76:
return (*SURFACE, 255)
return (*BG, 255)
def bmp_data(size: int) -> bytes:
header = struct.pack("<IiiHHIIiiII", 40, size, size * 2, 1, 32, 0, size * size * 4, 0, 0, 0, 0)
xor = bytearray()
for y in range(size - 1, -1, -1):
for x in range(size):
r, g, b, a = pixel(size, x, y)
xor += bytes((b, g, r, a))
return header + bytes(xor)
def write_ico(path: Path) -> None:
blocks = [bmp_data(size) for size in SIZES]
count = len(SIZES)
total_size = 6 + 16 * count + sum(len(b) for b in blocks)
out = bytearray(struct.pack("<HHH", 0, 1, count))
offset = 6 + 16 * count
for size, block in zip(SIZES, blocks):
entry = struct.pack(
"<BBBBHHII",
size % 256,
size % 256,
0,
0,
1,
32,
len(block),
offset,
)
out += entry
offset += len(block)
for block in blocks:
out += block
path.write_bytes(bytes(out))
def write_png(path: Path, size: int) -> None:
raw = bytearray()
for y in range(size):
raw += b"\x00"
for x in range(size):
raw += bytes(pixel(size, x, y))
ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
chunks = []
for chunk_type, data in ((b"IHDR", ihdr), (b"IDAT", zlib.compress(bytes(raw), 9)), (b"IEND", b"")):
crc = zlib.crc32(chunk_type + data) & 0xFFFFFFFF
chunks.append(struct.pack(">I", len(data)) + chunk_type + data + struct.pack(">I", crc))
path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"".join(chunks))
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
write_ico(OUT_DIR / "pulsegate.ico")
write_png(OUT_DIR / "pulsegate.png", 256)
print(f"Assets erzeugt in {OUT_DIR}")
return 0
if __name__ == "__main__":
sys.exit(main())