projects/vpn: Heron rootlos auf macOS (GlobalProtect/Duo-SAML via WKWebView + HIP), Reiher-Icon

This commit is contained in:
marcus.hinz
2026-06-25 16:56:43 +02:00
parent d64b27ab88
commit f044190474
6 changed files with 363 additions and 93 deletions
+7 -4
View File
@@ -9,10 +9,13 @@ und eigenes Browser-Profil — mehrere VPNs laufen parallel ohne Konflikt.
- **wuerth_vpn_tray.py** — Würth, Cisco AnyConnect (`--protocol=anyconnect`),
User+Passwort. Fertig und in Betrieb.
- **heron_vpn_tray.py** — Heron, GlobalProtect (`--protocol=gp`). Gerüst fertig;
der SAML-POST-/Duo-Login ist auf macOS nicht sauber rootlos lösbar
(openconnect `--external-browser` bedient nur SAML-REDIRECT; gp-saml-gui
braucht WebKit2GTK). Unter Linux via gp-saml-gui umsetzbar.
- **heron_vpn_tray.py** — Heron, GlobalProtect (`--protocol=gp`), Duo-SAML.
Läuft rootlos auf macOS: Der SAML-Login (`heron_gp_saml.py`) erfolgt in einem
nativen WKWebView (pyobjc) mit GlobalProtect-User-Agent, greift den
`prelogin-cookie` aus den HTTP-Antwort-Headern ab (was QtWebEngine nicht kann)
und startet damit openconnect inkl. HIP-Report (`--csd-wrapper`) über ocproxy.
Der SAML-Teil ist ein eigener CLT-Python-Prozess (braucht pyobjc), die Tray-App
ruft ihn und reicht den Cookie an openconnect.
## Voraussetzungen
+18 -15
View File
@@ -14,25 +14,28 @@ cd "$APPS"
# --- 0. Icon erzeugen (H-Badge -> icon_heron.icns) ---
TMP="$(mktemp -d)"
QT_QPA_PLATFORM=offscreen "$PY" - "$TMP/h.png" <<'PYEOF'
QT_QPA_PLATFORM=offscreen "$PY" - "$APPS/heron_mask.png" "$TMP/h.png" <<'PYEOF'
import sys
from PyQt6.QtGui import QImage, QPainter, QColor, QFont, QGuiApplication
from PyQt6.QtGui import QImage, QPainter, QColor, qAlpha, qRgba
from PyQt6.QtCore import Qt
_app = QGuiApplication([]) # noetig fuer Font-Zugriff
# Reiher-Maske gruen einfaerben und mittig auf quadratische Flaeche setzen.
mask = QImage(sys.argv[1]).convertToFormat(QImage.Format.Format_ARGB32)
tgt = QColor("#2e7d32")
for y in range(mask.height()):
for x in range(mask.width()):
a = qAlpha(mask.pixel(x, y))
if a:
mask.setPixel(x, y, qRgba(tgt.red(), tgt.green(), tgt.blue(), a))
S = 1024
img = QImage(S, S, QImage.Format.Format_ARGB32)
img.fill(Qt.GlobalColor.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setBrush(QColor("#2e7d32"))
p.setPen(Qt.PenStyle.NoPen)
m = int(S * 0.09)
p.drawRoundedRect(m, m, S - 2 * m, S - 2 * m, S * 0.18, S * 0.18)
p.setPen(QColor("#ffffff"))
f = QFont(); f.setBold(True); f.setPixelSize(int(S * 0.6)); p.setFont(f)
p.drawText(img.rect(), Qt.AlignmentFlag.AlignCenter, "H")
inner = int(S * 0.80)
scaled = mask.scaled(inner, inner, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
canvas = QImage(S, S, QImage.Format.Format_ARGB32)
canvas.fill(Qt.GlobalColor.transparent)
p = QPainter(canvas)
p.drawImage((S - scaled.width()) // 2, (S - scaled.height()) // 2, scaled)
p.end()
img.save(sys.argv[1])
canvas.save(sys.argv[2])
PYEOF
ISET="$TMP/icon.iconset"; mkdir -p "$ISET"
for s in 16 32 128 256 512; do
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
PoC: GlobalProtect SAML-POST (Duo) auf macOS rootlos.
Holt den GP-Prelogin, fuehrt das Duo-SAML in einem eingebetteten WKWebView durch,
greift den `prelogin-cookie`/`saml-username` aus den HTTP-Antwort-Headern ab
(was QtWebEngine nicht kann, das native WKWebView ueber den Navigation-Response-
Delegate aber schon) und startet damit openconnect rootlos ueber ocproxy.
Start: /Library/Developer/CommandLineTools/usr/bin/python3 ~/apps/heron_gp_saml.py
"""
from __future__ import annotations
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
import objc
from Cocoa import (
NSApplication, NSObject, NSWindow, NSURL, NSMakeRect,
NSBackingStoreBuffered,
)
from Foundation import NSHTTPURLResponse, NSHTTPCookie
from WebKit import WKWebView, WKWebViewConfiguration
SERVER = "ssl.heron.at"
PORT = 11090
GP_UA = "PAN GlobalProtect" # GP liefert den prelogin-cookie nur dem Client-UA
NSWindowStyleMaskTitled = 1
NSWindowStyleMaskClosable = 2
NSWindowStyleMaskResizable = 8
WKNavigationResponsePolicyAllow = 1
captured: dict[str, str] = {}
def _tool(n: str) -> str:
return shutil.which(n) or (
f"/opt/homebrew/bin/{n}" if os.path.exists(f"/opt/homebrew/bin/{n}") else n
)
def _hipreport() -> str | None:
import glob
for p in ["/opt/homebrew/opt/openconnect/libexec/openconnect/hipreport.sh",
*glob.glob("/opt/homebrew/Cellar/openconnect/*/libexec/openconnect/hipreport.sh"),
"/usr/local/opt/openconnect/libexec/openconnect/hipreport.sh"]:
if os.path.exists(p):
return p
return None
def prelogin() -> tuple[str, str]:
"""Liefert (saml_html, sessid)."""
# Gateway-Prelogin direkt (nicht das Portal) -> liefert den Gateway-Cookie.
url = (f"https://{SERVER}/ssl-vpn/prelogin.esp"
"?tmp=tmp&clientVer=4100&clientos=Mac")
req = urllib.request.Request(url, method="POST", data=b"",
headers={"User-Agent": GP_UA})
with urllib.request.urlopen(req, timeout=20) as r:
xml = r.read().decode("utf-8", "replace")
sessid = ""
for c in r.headers.get_all("Set-Cookie") or []:
m = re.search(r"SESSID=([^;]+)", c)
if m:
sessid = m.group(1)
m = re.search(r"<saml-request>(.*?)</saml-request>", xml, re.S)
if not m:
raise SystemExit("Kein <saml-request> im Prelogin.")
return base64.b64decode(m.group(1)).decode("utf-8", "replace"), sessid
class Delegate(NSObject):
def webView_decidePolicyForNavigationAction_decisionHandler_(
self, webView, navAction, handler):
u = str(navAction.request().URL().absoluteString())
print("NAV ->", u[:120], file=sys.stderr)
if u.startswith("globalprotectcallback:"):
payload = u[len("globalprotectcallback:"):]
try:
dec = base64.b64decode(payload).decode("utf-8", "replace")
except Exception:
dec = payload
print("CALLBACK-Payload:", dec[:500], file=sys.stderr)
for key in ("prelogin-cookie", "saml-username",
"portal-userauthcookie"):
m = re.search(rf"{key}[=:>]+\s*([^<&\"']+)", dec)
if m:
captured[key] = m.group(1).strip()
handler(0) # Cancel
if "prelogin-cookie" in captured:
NSApplication.sharedApplication().stop_(None)
return
handler(1) # Allow
def webView_decidePolicyForNavigationResponse_decisionHandler_(
self, webView, navResponse, handler):
resp = navResponse.response()
if resp.isKindOfClass_(NSHTTPURLResponse):
for k, v in resp.allHeaderFields().items():
lk = str(k).lower()
if lk in ("prelogin-cookie", "saml-username",
"portal-userauthcookie", "saml-auth-status"):
captured[lk] = str(v)
handler(WKNavigationResponsePolicyAllow)
if "prelogin-cookie" in captured and "saml-username" in captured:
NSApplication.sharedApplication().stop_(None)
def webView_didFinishNavigation_(self, webView, nav):
# Nur die SAML-Weiterleitungsform (id=myform) anstossen, sonst wuerde
# auch das Duo-Login-Formular dauernd neu abgesendet (Flackern).
webView.evaluateJavaScript_completionHandler_(
"var f=document.getElementById('myform'); if(f){f.submit();}", None)
def run_saml(html: str, sessid: str):
app = NSApplication.sharedApplication()
app.setActivationPolicy_(0) # Regular -> Fenster bekommt sauber den Fokus
rect = NSMakeRect(0, 0, 540, 720)
style = (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable
| NSWindowStyleMaskResizable)
win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
rect, style, NSBackingStoreBuffered, False)
win.setTitle_("Heron SSO-Login")
cfg = WKWebViewConfiguration.alloc().init()
wv = WKWebView.alloc().initWithFrame_configuration_(rect, cfg)
wv.setCustomUserAgent_(GP_UA)
delegate = Delegate.alloc().init()
wv.setNavigationDelegate_(delegate)
win.setContentView_(wv)
win.makeKeyAndOrderFront_(None)
base = NSURL.URLWithString_(f"https://{SERVER}/")
def load(_=None):
wv.loadHTMLString_baseURL_(html, base)
if sessid:
store = cfg.websiteDataStore().httpCookieStore()
cookie = NSHTTPCookie.cookieWithProperties_({
"Name": "SESSID", "Value": sessid, "Domain": SERVER,
"Path": "/", "Secure": "TRUE",
})
store.setCookie_completionHandler_(cookie, load)
else:
load()
app.activateIgnoringOtherApps_(True)
app.run()
def main():
auth_only = "--auth-only" in sys.argv
html, sessid = prelogin()
run_saml(html, sessid)
if "prelogin-cookie" not in captured:
print(f"Kein prelogin-cookie erhalten. Erfasst: {list(captured)}",
file=sys.stderr)
raise SystemExit(2)
if auth_only:
# Nur den Cookie als JSON auf stdout -> die Tray-App liest ihn.
print(json.dumps({
"prelogin-cookie": captured["prelogin-cookie"],
"saml-username": captured.get("saml-username", ""),
}))
return
print("Erfasst:", {k: v[:20] + "" for k, v in captured.items()},
file=sys.stderr)
cmd = [
_tool("openconnect"), "--protocol=gp",
"--user", captured.get("saml-username", ""),
"--usergroup=gateway:prelogin-cookie", "--passwd-on-stdin",
"--script", f"{_tool('ocproxy')} -D {PORT}", "--script-tun",
]
hip = _hipreport()
if hip:
cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway
cmd.append(SERVER)
print("Starte:", " ".join(cmd), file=sys.stderr)
p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
p.stdin.write((captured["prelogin-cookie"] + "\n").encode())
p.stdin.flush()
p.wait()
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+140 -74
View File
@@ -8,14 +8,18 @@ VPN schickt und alles andere direkt. Start/Stop ueber das Tray-Icon.
Eigene Ports/Profil/Bundle -> laeuft parallel zur Wuerth-App.
Abhaengigkeiten:
openconnect + ocproxy auf dem PATH (macOS: brew install openconnect ocproxy)
Auth: GlobalProtect-SAML (Duo). Der SAML-Login laeuft als eigener Cocoa/pyobjc-
Prozess (heron_gp_saml.py --auth-only, via CLT-Python) und liefert nur den
prelogin-cookie; diese App startet damit openconnect (+HIP-Report) ueber ocproxy.
ZU FUELLEN: VPN_USER, INTERNAL_GLOB, START_URL, BOOKMARKS.
Abhaengigkeiten:
openconnect + ocproxy (macOS: brew install openconnect ocproxy),
CLT-Python mit pyobjc (pyobjc-framework-WebKit) fuer heron_gp_saml.py.
"""
from __future__ import annotations
import glob as _glob
import json
import os
import platform
@@ -26,30 +30,32 @@ import subprocess
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from PyQt6.QtCore import QTimer, Qt
from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap
from PyQt6.QtCore import QProcess, QProcessEnvironment, QTimer, Qt
from PyQt6.QtGui import (
QAction, QColor, QIcon, QImage, QPainter, QPixmap, qAlpha, qRgba,
)
from PyQt6.QtWidgets import (
QApplication, QSystemTrayIcon, QMenu, QInputDialog, QLineEdit, QMessageBox,
QApplication, QSystemTrayIcon, QMenu, QMessageBox,
)
# ===== CONFIG =================================================================
APP_NAME = "Heron VPN"
PROTOCOL = "gp" # GlobalProtect (Palo Alto)
SERVER = "ssl.heron.at"
AUTH = "saml" # "saml" = SSO via externem Browser, "password"
VPN_USER = "marcus.hinz@robotunits.com" # nur fuer AUTH="password" noetig
PORT = 11090 # SOCKS5 (ocproxy) andere Ports als Wuerth
PORT = 11090 # SOCKS5 (ocproxy) andere Ports als Wuerth
PACPORT = 11091 # lokaler PAC-HTTP-Server
INTERNAL_GLOB = "*.heron.at" # <-- interne Hosts anpassen (ueber VPN)
# Hosts, die ueber das VPN laufen (Domain-Globs und/oder IP-Globs).
INTERNAL_GLOBS = ["*.heron.at", "172.16.*"]
PROFILE = os.path.expanduser("~/.heron-edge")
START_URL = "https://robotunits.com" # <-- gewuenschte Startseite
START_URL = "https://robotunits.com"
LOGFILE = os.path.expanduser("~/.heron-vpn.log")
PAC_NAME = "heron.pac"
CLT_PYTHON = "/Library/Developer/CommandLineTools/usr/bin/python3" # hat pyobjc
# Lesezeichen, die ins frische Profil gesaet werden (Name, URL). <-- anpassen
# Lesezeichen, die ins frische Profil gesaet werden (Name, URL).
BOOKMARKS: list[tuple[str, str]] = [
("Heron", "https://ssl.heron.at"),
("Storybook", "http://172.16.80.30:30060/"),
("Heron", "https://heron.at"),
]
# =============================================================================
@@ -123,20 +129,31 @@ def _tool(name: str) -> str:
return name
def _external_browser() -> str:
"""Programm, das openconnect fuer das SAML-Login mit der SSO-URL aufruft."""
s = _system()
if s == "Darwin":
return "/usr/bin/open" # oeffnet Standardbrowser mit der URL
if s == "Windows":
return "cmd" # TODO: 'cmd /c start' verifizieren
return shutil.which("xdg-open") or "xdg-open"
def _saml_script() -> str:
"""Pfad zu heron_gp_saml.py (im Bundle unter Resources, sonst neben uns)."""
base = os.environ.get(
"RESOURCEPATH", os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "heron_gp_saml.py")
def _hipreport() -> str | None:
for p in ["/opt/homebrew/opt/openconnect/libexec/openconnect/hipreport.sh",
*_glob.glob("/opt/homebrew/Cellar/openconnect/*/libexec/openconnect/hipreport.sh"),
"/usr/local/opt/openconnect/libexec/openconnect/hipreport.sh"]:
if os.path.exists(p):
return p
return None
def _resolver_rules() -> str:
return " , ".join(f"MAP {g} ~NOTFOUND" for g in INTERNAL_GLOBS)
# ---------- PAC-Server (in-process) ------------------------------------------
_PAC_COND = " || ".join(f'shExpMatch(host, "{g}")' for g in INTERNAL_GLOBS)
PAC_BODY = (
"function FindProxyForURL(url, host) {\n"
f' if (shExpMatch(host, "{INTERNAL_GLOB}")) {{\n'
f" if ({_PAC_COND}) {{\n"
f' return "SOCKS5 127.0.0.1:{PORT}";\n'
" }\n"
' return "DIRECT";\n'
@@ -163,28 +180,46 @@ def start_pac_server() -> ThreadingHTTPServer:
return srv
# ---------- Icon (generiertes "H"-Badge in Zustandsfarbe) --------------------
# ---------- Icon (Reiher-Silhouette in Zustandsfarbe) ------------------------
_ICON_CACHE: dict[str, QIcon] = {}
_MASK: QImage | None = None
def badge_icon(color: str) -> QIcon:
def _heron_mask() -> QImage:
global _MASK
if _MASK is None:
base = os.environ.get(
"RESOURCEPATH", os.path.dirname(os.path.abspath(__file__)))
img = QImage(os.path.join(base, "heron_mask.png"))
_MASK = (img.scaled(44, 44, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
.convertToFormat(QImage.Format.Format_ARGB32)
if not img.isNull() else QImage())
return _MASK
def heron_icon(color: str) -> QIcon:
if color in _ICON_CACHE:
return _ICON_CACHE[color]
pm = QPixmap(44, 44)
pm.fill(Qt.GlobalColor.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setBrush(QColor(color))
p.setPen(Qt.PenStyle.NoPen)
p.drawRoundedRect(4, 4, 36, 36, 9, 9)
p.setPen(QColor("#ffffff"))
f = p.font()
f.setBold(True)
f.setPixelSize(26)
p.setFont(f)
p.drawText(pm.rect(), Qt.AlignmentFlag.AlignCenter, "H")
p.end()
icon = QIcon(pm)
m = _heron_mask()
if m.isNull(): # Fallback: einfacher Punkt
pm = QPixmap(22, 22)
pm.fill(Qt.GlobalColor.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setBrush(QColor(color))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(4, 4, 14, 14)
p.end()
return QIcon(pm)
tgt = QColor(color)
o = QImage(m)
for y in range(o.height()):
for x in range(o.width()):
a = qAlpha(o.pixel(x, y))
if a:
o.setPixel(x, y, qRgba(tgt.red(), tgt.green(), tgt.blue(), a))
icon = QIcon(QPixmap.fromImage(o))
_ICON_CACHE[color] = icon
return icon
@@ -195,13 +230,13 @@ class VpnTray:
self.app = app
self.oc: subprocess.Popen | None = None
self.browser: subprocess.Popen | None = None
self.vpn_pw: str | None = None
self.auth_proc: QProcess | None = None
self.state = "disconnected"
self.pac = start_pac_server()
self.tray = QSystemTrayIcon()
self.tray.setIcon(badge_icon("#9a9a9a"))
self.tray.setIcon(heron_icon("#9a9a9a"))
self.tray.setToolTip(f"{APP_NAME} getrennt")
self.menu = QMenu()
@@ -240,7 +275,7 @@ class VpnTray:
"connected": ("#2e7d32", "Verbunden"),
"error": ("#cc3333", "Fehler"),
}[state]
self.tray.setIcon(badge_icon(color))
self.tray.setIcon(heron_icon(color))
self.tray.setToolTip(f"{APP_NAME} {label}")
self.act_status.setText(label)
self.act_connect.setEnabled(state in ("disconnected", "error"))
@@ -248,48 +283,79 @@ class VpnTray:
self.act_open.setEnabled(state == "connected")
def connect(self):
base = [
_tool("openconnect"),
f"--protocol={PROTOCOL}",
"--script", f"{_tool('ocproxy')} -D {PORT}",
"--script-tun",
if self.auth_proc is not None:
return
self._set_state("connecting")
# SAML-Login als eigener pyobjc/WKWebView-Prozess -> liefert Cookie als JSON.
self.auth_proc = QProcess()
# Bereinigte Umgebung: sonst erbt das CLT-Python die PYTHONHOME/PATH des
# py2app-Bundles und findet sein pyobjc nicht (Subprozess stirbt sofort).
env = QProcessEnvironment.systemEnvironment()
for v in ("PYTHONHOME", "PYTHONPATH", "PYTHONEXECUTABLE",
"PYTHONNOUSERSITE", "PYTHONDONTWRITEBYTECODE"):
env.remove(v)
self.auth_proc.setProcessEnvironment(env)
self.auth_proc.finished.connect(self._on_auth_done)
self.auth_proc.start(CLT_PYTHON, [_saml_script(), "--auth-only"])
if not self.auth_proc.waitForStarted(3000):
self.auth_proc = None
self._set_state("error")
QMessageBox.critical(None, APP_NAME,
"SAML-Login (CLT-Python/pyobjc) nicht startbar.")
def _on_auth_done(self, code, status):
out = bytes(self.auth_proc.readAllStandardOutput()).decode("utf-8", "replace")
self.auth_proc = None
auth = None
for line in out.strip().splitlines():
line = line.strip()
if line.startswith("{"):
try:
auth = json.loads(line)
except Exception:
pass
if not auth or not auth.get("prelogin-cookie"):
self._set_state("error")
return
self._start_openconnect(auth.get("saml-username", ""),
auth["prelogin-cookie"])
def _start_openconnect(self, user: str, cookie: str):
# Reste eines frueheren Laufs wegraeumen, sonst Portkonflikt auf SOCKS.
if _system() in ("Darwin", "Linux"):
subprocess.run(["pkill", "-f", f"ocproxy -D {PORT}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
cmd = [
_tool("openconnect"), f"--protocol={PROTOCOL}",
"--user", user, "--usergroup=gateway:prelogin-cookie",
"--passwd-on-stdin",
"--script", f"{_tool('ocproxy')} -D {PORT}", "--script-tun",
]
send_pw = None
if AUTH == "saml":
# SSO: openconnect oeffnet den Standardbrowser fuer das Login.
cmd = base + ["--external-browser", _external_browser(),
f"https://{SERVER}"]
else:
if not self.vpn_pw:
pw, ok = QInputDialog.getText(
None, APP_NAME, f"VPN-Passwort fuer {VPN_USER}:",
QLineEdit.EchoMode.Password,
)
if not (ok and pw):
return
self.vpn_pw = pw
send_pw = self.vpn_pw
cmd = base + ["--user", VPN_USER, "--passwd-on-stdin",
f"https://{SERVER}"]
hip = _hipreport()
if hip:
cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway
cmd.append(SERVER)
try:
self._log = open(LOGFILE, "wb")
self.oc = subprocess.Popen(
cmd, stdin=subprocess.PIPE if send_pw else None,
cmd, stdin=subprocess.PIPE,
stdout=self._log, stderr=subprocess.STDOUT,
)
if send_pw:
self.oc.stdin.write((send_pw + "\n").encode())
self.oc.stdin.flush()
self.oc.stdin.write((cookie + "\n").encode())
self.oc.stdin.flush()
except FileNotFoundError:
self._set_state("error")
QMessageBox.critical(None, APP_NAME, "openconnect nicht gefunden.")
return
self._set_state("connecting")
# bleibt "connecting"; _tick erkennt den offenen SOCKS-Port
def disconnect(self):
if self.auth_proc is not None:
try:
self.auth_proc.kill()
except Exception:
pass
self.auth_proc = None
self._kill_browser()
if self.oc and self.oc.poll() is None:
try:
@@ -318,7 +384,7 @@ class VpnTray:
argv = browser_argv() + [
f"--user-data-dir={PROFILE}",
f"--proxy-pac-url=http://127.0.0.1:{PACPORT}/{PAC_NAME}",
f"--host-resolver-rules=MAP {INTERNAL_GLOB} ~NOTFOUND",
f"--host-resolver-rules={_resolver_rules()}",
"--no-first-run", "--no-default-browser-check",
"--disable-features=msSmartScreenProtection",
"--test-type",
+2
View File
@@ -6,6 +6,7 @@ Bauen: cd ~/apps && bash build_heron_app.sh
from setuptools import setup
APP = ["heron_vpn_tray.py"]
DATA_FILES = ["heron_gp_saml.py", "heron_mask.png"] # Auth-Prozess + Icon-Maske
OPTIONS = {
"iconfile": "icon_heron.icns",
"packages": ["PyQt6"],
@@ -23,6 +24,7 @@ OPTIONS = {
setup(
app=APP,
name="HeronVPN",
data_files=DATA_FILES,
options={"py2app": OPTIONS},
setup_requires=["py2app"],
)