Files
aza/AzA march 2026/aza_empfang_app.py

536 lines
16 KiB
Python
Raw Permalink Normal View History

2026-04-19 20:41:37 +02:00
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AZA Empfang - Schlanke Desktop-Huelle fuer die Empfangs-Weboberflaeche.
2026-04-21 10:05:37 +02:00
2026-04-22 22:33:46 +02:00
Unabhaengig von AzA Office / Hauptfenster: Standard-URL ist die oeffentliche
Empfang-Instanz (empfang.aza-medwork.ch). Kein lokales Backend noetig.
2026-04-21 10:05:37 +02:00
2026-04-22 22:33:46 +02:00
URL-Reihenfolge:
1) Umgebung AZA_EMPFANG_URL oder EMPFANG_URL (volle Basis, siehe unten)
2) backend_url.txt neben der EXE (optional, erste nicht-leere Zeile)
3) Im EXE-Bundle nur mit AZA_EMPFANG_USE_BUNDLED_URL=1
4) Fallback: https://empfang.aza-medwork.ch/empfang/
Lokale URLs (localhost / 127.0.0.1 / 192.168...): bei gebundelter EXE nur mit
AZA_EMPFANG_ALLOW_LOCAL=1, sonst Fallback auf (4) damit die Huelle ohne
Haupt-App und ohne laufenden lokalen Server funktioniert.
Standard: Laedt die URL direkt im Fenster (kein iframe).
Optional: AZA_EMPFANG_IFRAME=1 = alte iframe-Huelle mit HTML-Toolbar.
2026-04-19 20:41:37 +02:00
"""
import json
import os
import ssl
import sys
import threading
import webbrowser
_APP_TITLE = "AZA Empfang"
_MIN_SIZE = (380, 500)
_DEFAULT_W, _DEFAULT_H = 480, 820
_PUBLIC_EMPFANG_URL = "https://empfang.aza-medwork.ch"
def _data_dir():
if getattr(sys, "frozen", False):
return sys._MEIPASS
return os.path.dirname(os.path.abspath(__file__))
def _user_dir():
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
2026-04-22 22:33:46 +02:00
def _normalize_to_empfang_url(base: str) -> str:
"""Aus Basis-URL (Host oder .../empfang) wird .../empfang/ mit Slash am Ende."""
b = (base or "").strip().rstrip("/")
if not b:
b = _PUBLIC_EMPFANG_URL.rstrip("/")
if b.endswith("/empfang"):
return b + "/"
return b + "/empfang/"
def _is_local_or_lan_url(url: str) -> bool:
u = (url or "").lower()
if "localhost" in u or "127.0.0.1" in u:
return True
if u.startswith("http://192.168.") or u.startswith("http://10."):
return True
if u.startswith("https://192.168.") or u.startswith("https://10."):
return True
return False
def _read_backend_url_file_in_dir(dir_path: str) -> str | None:
2026-04-19 20:41:37 +02:00
try:
2026-04-22 22:33:46 +02:00
p = os.path.join(dir_path, "backend_url.txt")
if not os.path.isfile(p):
return None
2026-04-19 20:41:37 +02:00
with open(p, "r", encoding="utf-8") as f:
2026-04-22 22:33:46 +02:00
for ln in f:
s = ln.strip()
if s and not s.startswith("#"):
return s.rstrip("/")
2026-04-19 20:41:37 +02:00
except Exception:
2026-04-22 22:33:46 +02:00
return None
return None
def _empfang_url() -> str:
env = (
(os.environ.get("AZA_EMPFANG_URL") or os.environ.get("EMPFANG_URL") or "")
.strip()
)
if env:
return _normalize_to_empfang_url(env)
search_dirs: list[str] = [_user_dir()]
if getattr(sys, "frozen", False):
if os.environ.get("AZA_EMPFANG_USE_BUNDLED_URL", "").strip() == "1":
search_dirs.append(_data_dir())
else:
# Entwicklung: Skript-Verzeichnis (identisch zu _user_dir bei direktem Start)
dd = _data_dir()
if dd not in search_dirs:
search_dirs.append(dd)
raw: str | None = None
for d in search_dirs:
raw = _read_backend_url_file_in_dir(d)
if raw:
break
if raw:
if getattr(sys, "frozen", False) and _is_local_or_lan_url(raw):
if os.environ.get("AZA_EMPFANG_ALLOW_LOCAL", "").strip() != "1":
raw = None
if raw:
return _normalize_to_empfang_url(raw)
return _normalize_to_empfang_url(_PUBLIC_EMPFANG_URL)
2026-04-19 20:41:37 +02:00
_SETTINGS_FILE = os.path.join(_user_dir(), "empfang_app_settings.json")
def _load_settings() -> dict:
defaults = {"width": _DEFAULT_W, "height": _DEFAULT_H,
"x": None, "y": None, "on_top": False}
try:
with open(_SETTINGS_FILE, "r", encoding="utf-8") as f:
return {**defaults, **json.load(f)}
except Exception:
return defaults
def _save_settings(s: dict):
try:
with open(_SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(s, f, indent=2)
except Exception:
pass
class _Api:
def __init__(self, window, on_top: bool, url: str):
self._w = window
self._on_top = on_top
self._url = url
self._pin_lock = threading.Lock()
def check_url(self):
"""Diagnostic connectivity check (GET with SSL fallback)."""
from urllib.request import urlopen, Request
for verify in (True, False):
try:
ctx = ssl.create_default_context() if verify else ssl._create_unverified_context()
req = Request(self._url)
with urlopen(req, timeout=10, context=ctx) as r:
r.read(512)
return {"ok": True, "url": self._url, "error": ""}
except ssl.SSLError:
if verify:
continue
return {"ok": False, "url": self._url,
"error": "SSL/TLS-Zertifikatsfehler. Bitte IT kontaktieren."}
except Exception as e:
if verify:
continue
return {"ok": False, "url": self._url, "error": str(e)}
return {"ok": False, "url": self._url,
"error": "Server nicht erreichbar. Bitte Netzwerk pruefen."}
def open_in_browser(self):
2026-04-21 10:05:37 +02:00
"""Öffnet dieselbe Ziel-URL wie im Fenster (lokal oder Produktion)."""
webbrowser.open(self._url)
2026-04-19 20:41:37 +02:00
def toggle_on_top(self):
if not self._pin_lock.acquire(blocking=False):
return self._on_top
try:
self._on_top = not self._on_top
new_val = self._on_top
finally:
self._pin_lock.release()
def _apply():
try:
self._w.on_top = new_val
except Exception:
pass
threading.Thread(target=_apply, daemon=True).start()
return new_val
def get_on_top(self):
return self._on_top
def get_version(self):
try:
sys.path.insert(0, _data_dir())
from _build_info import BUILD_TIME, GIT_COMMIT
return f"Build: {BUILD_TIME} ({GIT_COMMIT})"
except Exception:
return "Entwicklungsversion"
def get_url(self):
return self._url
def get_public_url(self):
return _PUBLIC_EMPFANG_URL
_SHELL_HTML = r'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8}
#toolbar{
height:34px;
background:linear-gradient(135deg,#5B8DB3,#3a6d93);
display:flex;align-items:center;justify-content:space-between;
padding:0 10px;box-shadow:0 1px 4px rgba(0,0,0,.15);
position:relative;z-index:20;
}
.tb-l,.tb-r{display:flex;align-items:center;gap:6px}
.tb-title{color:#fff;font-size:12px;font-weight:600;letter-spacing:.3px}
.tb-btn{
background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);
color:#fff;border-radius:4px;padding:3px 9px;font-size:11px;
cursor:pointer;transition:all .15s;font-family:inherit;white-space:nowrap;
}
.tb-btn:hover{background:rgba(255,255,255,.2)}
.tb-btn.on{background:rgba(255,255,255,.35);border-color:rgba(255,255,255,.6);font-weight:600}
.tb-btn.busy{opacity:.5;pointer-events:none}
#content{position:absolute;top:34px;left:0;right:0;bottom:0}
#frame{width:100%;height:100%;border:none;display:none}
#loading{
position:absolute;top:0;left:0;right:0;bottom:0;
display:flex;flex-direction:column;align-items:center;justify-content:center;
background:#f0f4f8;z-index:10;
}
.spin{width:34px;height:34px;border:3px solid #dde8f0;border-top-color:#5B8DB3;
border-radius:50%;animation:sp .8s linear infinite;margin-bottom:16px}
@keyframes sp{to{transform:rotate(360deg)}}
.ld-t{color:#5B8DB3;font-size:13px;font-weight:500}
.ld-s{color:#8aa8bc;font-size:11px;margin-top:5px}
#error{
position:absolute;top:0;left:0;right:0;bottom:0;
display:none;flex-direction:column;align-items:center;justify-content:center;
background:#f0f4f8;padding:30px;text-align:center;z-index:10;
}
.er-i{font-size:44px;margin-bottom:14px;opacity:.65}
.er-h{font-size:15px;font-weight:600;color:#1a4d6d;margin-bottom:6px}
.er-m{font-size:12px;color:#6a8a9a;margin-bottom:20px;line-height:1.6}
.er-b{display:flex;gap:8px}
.er-btn{background:#5B8DB3;color:#fff;border:none;border-radius:6px;
padding:8px 18px;font-size:12px;cursor:pointer;font-weight:500;font-family:inherit}
.er-btn:hover{background:#4A7A9E}
.er-btn.s{background:#dde8f0;color:#1a4d6d}
.er-btn.s:hover{background:#ccd8e0}
</style>
</head>
<body>
<div id="toolbar">
<div class="tb-l"><span class="tb-title">AZA Empfang</span></div>
<div class="tb-r">
<button class="tb-btn" onclick="doReload()" title="Seite neu laden">&#8635; Laden</button>
<button class="tb-btn" onclick="doOpen()" title="Im Browser oeffnen">&#8599; Browser</button>
<button class="tb-btn" id="bp" onclick="doPin()" title="Immer im Vordergrund">&#128204;</button>
<button class="tb-btn" onclick="doInfo()" title="Version / Info">&#8505;</button>
</div>
</div>
<div id="content">
<div id="loading">
<div class="spin"></div>
<div class="ld-t">Empfang wird geladen</div>
<div class="ld-s">Verbindung wird hergestellt&hellip;</div>
</div>
<div id="error">
<div class="er-i">&#9888;&#65039;</div>
<div class="er-h">Empfang nicht erreichbar</div>
<div class="er-m" id="erMsg">Die Empfangsseite konnte nicht geladen werden.</div>
<div class="er-b">
<button class="er-btn" onclick="doReload()">&#8635; Neu laden</button>
<button class="er-btn s" onclick="doOpen()">&#8599; Im Browser oeffnen</button>
</div>
</div>
<iframe id="frame" src="about:blank"></iframe>
</div>
<script>
var RDY=false, URL='';
window.addEventListener('pywebviewready',function(){
RDY=true;
pywebview.api.get_url().then(function(u){URL=u;boot()});
pinInit();
});
function view(id){
['loading','error','frame'].forEach(function(k){
var e=document.getElementById(k);
e.style.display=(k===id)?(k==='frame'?'block':'flex'):'none';
});
}
function boot(){
view('loading');
var f=document.getElementById('frame');
var done=false;
f.onload=function(){
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
};
var cacheBust = '?_t=' + Date.now();
f.src=URL + (URL.indexOf('?')>=0 ? '&_t=' : '?_t=') + Date.now();
2026-04-19 20:41:37 +02:00
setTimeout(function(){
if(!done){diagnose()}
},12000);
}
async function diagnose(){
try{
var r=await pywebview.api.check_url();
if(r&&r.ok){
view('frame');
}else{
document.getElementById('erMsg').innerHTML=
(r&&r.error?r.error:'Server nicht erreichbar.')+'<br><br><small>URL: '+(r&&r.url||'')+'</small>';
view('error');
}
}catch(e){view('error')}
}
async function doReload(){
view('loading');
var f=document.getElementById('frame');
var done=false;
f.onload=function(){
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
};
f.src='about:blank';
setTimeout(function(){f.src=URL + (URL.indexOf('?')>=0 ? '&_t=' : '?_t=') + Date.now()},100);
2026-04-19 20:41:37 +02:00
setTimeout(function(){if(!done)diagnose()},12000);
}
function doOpen(){if(RDY)pywebview.api.open_in_browser()}
var pinBusy=false;
async function doPin(){
if(!RDY||pinBusy)return;
var b=document.getElementById('bp');
pinBusy=true;
b.classList.add('busy');
try{
var v=await pywebview.api.toggle_on_top();
pinUI(v);
}catch(e){}
setTimeout(function(){pinBusy=false;b.classList.remove('busy')},300);
}
async function pinInit(){
try{var v=await pywebview.api.get_on_top();pinUI(v)}catch(e){}
}
function pinUI(on){
var b=document.getElementById('bp');
if(on){b.classList.add('on');b.textContent='\uD83D\uDCCC An';b.title='Immer im Vordergrund (aktiv)'}
else {b.classList.remove('on');b.textContent='\uD83D\uDCCC';b.title='Immer im Vordergrund'}
}
async function doInfo(){
if(!RDY)return;
var v=await pywebview.api.get_version();
alert('AZA Empfang\n\n'+v+'\n'+URL);
}
</script>
</body>
</html>'''
def main():
try:
import webview
except ImportError:
print("FEHLER: pywebview ist nicht installiert.")
print("Bitte ausfuehren: pip install pywebview")
sys.exit(1)
2026-04-21 10:05:37 +02:00
try:
from webview.menu import Menu, MenuAction, MenuSeparator
except ImportError:
Menu = None # type: ignore
MenuAction = None # type: ignore
MenuSeparator = None # type: ignore
2026-04-19 20:41:37 +02:00
settings = _load_settings()
url = _empfang_url()
2026-04-21 10:05:37 +02:00
# iframe-Huelle (alt): nur setzen wenn noetig: AZA_EMPFANG_IFRAME=1
use_iframe_shell = os.environ.get("AZA_EMPFANG_IFRAME", "").strip() == "1"
2026-04-19 20:41:37 +02:00
w = max(_MIN_SIZE[0], min(1920, settings.get("width") or _DEFAULT_W))
h = max(_MIN_SIZE[1], min(1200, settings.get("height") or _DEFAULT_H))
x = settings.get("x")
y = settings.get("y")
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
if x < -200 or y < -200 or x > 3800 or y > 2200:
x, y = None, None
else:
x, y = None, None
2026-04-21 10:05:37 +02:00
on_top = bool(settings.get("on_top", False))
if use_iframe_shell:
window = webview.create_window(
_APP_TITLE,
html=_SHELL_HTML,
width=w,
height=h,
x=x,
y=y,
min_size=_MIN_SIZE,
on_top=on_top,
text_select=True,
background_color="#f0f4f8",
)
api = _Api(window, on_top, url)
window.expose(
api.check_url,
api.open_in_browser,
api.toggle_on_top,
api.get_on_top,
api.get_version,
api.get_url,
api.get_public_url,
)
else:
window = webview.create_window(
_APP_TITLE,
url=url,
width=w,
height=h,
x=x,
y=y,
min_size=_MIN_SIZE,
on_top=on_top,
text_select=True,
background_color="#f0f4f8",
)
api = _Api(window, on_top, url)
2026-04-22 22:33:46 +02:00
window.expose(
api.toggle_on_top,
api.get_on_top,
api.open_in_browser,
api.get_version,
api.get_url,
api.get_public_url,
api.check_url,
)
2026-04-21 10:05:37 +02:00
def _reload():
try:
window.evaluate_js("window.location.reload()")
except Exception as exc:
print(f"[AZA Empfang] Neu laden: {exc}")
def _info_box():
try:
msg = f"{api.get_version()}\n\n{api._url}"
try:
import ctypes
ctypes.windll.user32.MessageBoxW(0, msg, _APP_TITLE, 0)
except Exception:
print(msg)
except Exception:
pass
def _toggle_pin_menu():
api.toggle_on_top()
menu = None
if Menu is not None:
menu = [
Menu(
"Empfang",
[
MenuAction("Neu laden", _reload),
MenuAction("Im Browser \u00f6ffnen", api.open_in_browser),
MenuAction("Immer im Vordergrund", _toggle_pin_menu),
MenuSeparator(),
MenuAction("Version / Info", _info_box),
],
),
]
2026-04-19 20:41:37 +02:00
def _on_closing():
try:
_save_settings({
2026-04-21 10:05:37 +02:00
"x": window.x,
"y": window.y,
"width": window.width,
"height": window.height,
2026-04-19 20:41:37 +02:00
"on_top": api._on_top,
})
except Exception:
pass
return True
try:
window.events.closing += _on_closing
except Exception:
pass
try:
2026-04-21 10:05:37 +02:00
if use_iframe_shell:
webview.start()
elif menu:
try:
webview.start(menu=menu)
except TypeError:
webview.start()
else:
webview.start()
2026-04-19 20:41:37 +02:00
except Exception as e:
print(f"[AZA Empfang] Fehler: {e}")
if __name__ == "__main__":
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"ch.aza-medwork.empfang.v2")
except Exception:
pass
try:
main()
except Exception as e:
print(f"[AZA Empfang] Kritischer Fehler: {e}")
import traceback
traceback.print_exc()