Phase 4: Webapp-Kategorie-Menü-Einordnung + Kiosk-Modus, Chromium+Edge-Merkmale

Webapps bekommen zwei neue, optionale Eigenschaften (Migration 0043):
kategorie_id (FK auf kategorien, nullable) und kiosk_modus (Boolean).
Kein eigener "Webapps"-Menüpunkt (per Nutzer-Feedback in der Plan-Runde
verworfen) - stattdessen ordnet sich eine Webapp optional einer der
bestehenden App-Kategorien zu, KATEGORIE_ZU_XDG_CATEGORIES übersetzt das
in eine echte Freedesktop-Categories=-Zeile fürs .desktop-Menü (Default
unverändert Network;WebBrowser; ohne gewählte Kategorie).

Kiosk-Modus: bei aktiviertem kiosk_modus löst der Server (nicht das
Gerät) anhand der Workspace-Merkmal-Zuordnung auf, welcher Browser das
Kiosk-Fenster öffnet (Chrome bevorzugt, Chromium Fallback, sonst normaler
Tab) - _resolve_kiosk_browser() in fetch_workspace_webapps().

Migration 0044: Chromium + Microsoft Edge als neue Browser-Merkmale
(Google Chrome existiert bereits aus Migration 0031). Chromium bewusst
NUR für debian-image/mint-image blueprint-verknüpft, nicht ubuntu-image -
recherchiert: Ubuntus "chromium"-Paket ist seit 19.10 nur ein
transitionelles Paket, das im Hintergrund Snap nachzieht (passt nicht zu
unserem Modell), während Debian und Mint ein echtes .deb bauen. Microsoft
Edge über das offizielle, distributionsunabhängige Microsoft-Repo auf
allen drei Backends.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-11 11:10:33 +02:00
1 parent b686d87c4b
commit 4706870774
3 files changed
+182 -32

No files matched your search

+125 -32
View File
@@ -213,12 +213,16 @@ class CreateWebappRequest(BaseModel):
name: str
url: str
sort_order: int = 0
kategorie_id: str | None = None
kiosk_modus: bool = False
class UpdateWebappRequest(BaseModel):
name: str
url: str
sort_order: int = 0
kategorie_id: str | None = None
kiosk_modus: bool = False
class CreateWebappTemplateRequest(BaseModel):
@@ -804,6 +808,60 @@ def fetch_favicon(page_url: str) -> tuple[bytes, str] | None:
return _fetch_url_bytes(fallback_url, FAVICON_MAX_BYTES)
# Phase 4 des GUI-Ausbaus (11.09.2026): Kategorie-Key -> echter Freedesktop-
# Categories=-Wert fuers .desktop-Menue einer Webapp (siehe kategorien-
# Tabelle). Unsere eigenen Kategorie-Namen sind keine gueltigen XDG-Menue-
# Kategorien, diese Abbildung uebersetzt dazwischen. Mehrfachwerte (durch
# Semikolon getrennt, mit abschliessendem Semikolon - Pflicht im
# .desktop-Format) helfen Menues, die eine Kategorie nicht kennen, noch
# eine plausible Alternative zu finden.
KATEGORIE_ZU_XDG_CATEGORIES = {
"bildung-mathe-naturwissenschaft": "Education;Science;",
"bildung-sprache-schreiben": "Education;",
"kreativ-grafik": "Graphics;",
"musik-audio": "AudioVideo;Audio;",
"buero-produktivitaet": "Office;",
"programmieren-making": "Development;",
"internet-kommunikation": "Network;",
"unterrichtswerkzeuge": "Education;",
"individuelle-systemkonfiguration": "Settings;",
"konfig-identitaet-netzwerk": "Settings;",
"konfig-system": "Settings;",
"haertung": "Settings;",
"app-store": "Network;",
}
# Unveraenderter alter Default (keine Kategorie gewaehlt) - landet wie
# bisher meist im "Internet"-Menueordner.
WEBAPP_XDG_CATEGORIES_DEFAULT = "Network;WebBrowser;"
# Kiosk-Modus (Phase 4): Chrome bevorzugt, Chromium Fallback - beide nur
# nutzbar, wenn das jeweilige Browser-Merkmal im Workspace aktiv ist
# (Nutzer-Entscheidung aus der Plan-Klaerungsrunde: "vermutlich Chrome,
# weil Chromium etwas abgespeckter ist"). Ohne einen der beiden faellt
# die .desktop-Vorlage auf den normalen xdg-open-Start zurueck.
KIOSK_BROWSER_PRIORITAET = ["google-chrome", "chromium"]
def _resolve_kiosk_browser(workspace_id: str) -> str | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.key
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
WHERE wm.workspace_id = %s AND m.key = ANY(%s)
""",
(workspace_id, KIOSK_BROWSER_PRIORITAET),
)
vorhandene = {row[0] for row in cur.fetchall()}
for browser in KIOSK_BROWSER_PRIORITAET:
if browser in vorhandene:
return browser
return None
def fetch_workspace_webapps(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
@@ -825,27 +883,38 @@ def fetch_workspace_webapps(device_id: str):
cur.execute(
"""
SELECT id, name, url, icon_filename
FROM workspace_webapps
WHERE workspace_id = %s
ORDER BY sort_order, name
SELECT w.id, w.name, w.url, w.icon_filename, w.kiosk_modus, k.key
FROM workspace_webapps w
LEFT JOIN kategorien k ON k.id = w.kategorie_id
WHERE w.workspace_id = %s
ORDER BY w.sort_order, w.name
""",
(workspace_id,),
)
return [
{
"id": str(webapp_id),
"name": name,
"url": url,
# Relativer Pfad, kein voller URL - der Server kennt
# seine eigene oeffentliche Adresse nirgends (siehe
# ANODE_URL in tuxflotte-installer/provisioning-agent,
# bewusst nur client-seitig bekannt, gleiches Muster wie
# bei GOLDEN_IMAGE_URL). Der Agent haengt ANODE_URL davor.
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
}
for webapp_id, name, url, icon_filename in cur.fetchall()
]
zeilen = cur.fetchall()
kiosk_browser = _resolve_kiosk_browser(workspace_id) if any(row[4] for row in zeilen) else None
return [
{
"id": str(webapp_id),
"name": name,
"url": url,
# Relativer Pfad, kein voller URL - der Server kennt
# seine eigene oeffentliche Adresse nirgends (siehe
# ANODE_URL in tuxflotte-installer/provisioning-agent,
# bewusst nur client-seitig bekannt, gleiches Muster wie
# bei GOLDEN_IMAGE_URL). Der Agent haengt ANODE_URL davor.
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
"categories": KATEGORIE_ZU_XDG_CATEGORIES.get(kategorie_key, WEBAPP_XDG_CATEGORIES_DEFAULT),
# None, wenn kein Kiosk-Modus gewuenscht ODER kein Kiosk-
# faehiger Browser im Workspace verfuegbar ist - die
# .desktop-Vorlage faellt in beiden Faellen auf den normalen
# xdg-open-Start zurueck (siehe webapp-shortcuts-Rolle).
"kiosk_browser": kiosk_browser if kiosk_modus else None,
}
for webapp_id, name, url, icon_filename, kiosk_modus, kategorie_key in zeilen
]
def fetch_assigned_blueprints(device_id: str):
@@ -2719,10 +2788,12 @@ def fetch_webapps_for_workspace(workspace_id: str):
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, url, icon_filename, sort_order
FROM workspace_webapps
WHERE workspace_id = %s
ORDER BY sort_order, name
SELECT w.id, w.name, w.url, w.icon_filename, w.sort_order,
w.kategorie_id, k.name, w.kiosk_modus
FROM workspace_webapps w
LEFT JOIN kategorien k ON k.id = w.kategorie_id
WHERE w.workspace_id = %s
ORDER BY w.sort_order, w.name
""",
(workspace_id,),
)
@@ -2733,12 +2804,18 @@ def fetch_webapps_for_workspace(workspace_id: str):
"url": url,
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
"sort_order": sort_order,
"kategorie_id": str(kategorie_id) if kategorie_id else None,
"kategorie_name": kategorie_name,
"kiosk_modus": kiosk_modus,
}
for webapp_id, name, url, icon_filename, sort_order in cur.fetchall()
for webapp_id, name, url, icon_filename, sort_order, kategorie_id, kategorie_name, kiosk_modus in cur.fetchall()
]
def create_webapp(workspace_id: str, name: str, url: str, sort_order: int):
def create_webapp(
workspace_id: str, name: str, url: str, sort_order: int,
kategorie_id: str | None = None, kiosk_modus: bool = False,
):
webapp_id = uuid4()
favicon = fetch_favicon(url)
icon_filename = _write_webapp_icon(webapp_id, favicon[0], favicon[1]) if favicon else None
@@ -2748,16 +2825,20 @@ def create_webapp(workspace_id: str, name: str, url: str, sort_order: int):
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspace_webapps (id, workspace_id, name, url, icon_filename, icon_content_type, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s)
INSERT INTO workspace_webapps
(id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order),
(webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus),
)
return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
def update_webapp(webapp_id: str, name: str, url: str, sort_order: int):
def update_webapp(
webapp_id: str, name: str, url: str, sort_order: int,
kategorie_id: str | None = None, kiosk_modus: bool = False,
):
# Favicon bei jeder Bearbeitung neu holen (einfacher als zu erkennen,
# ob sich nur die URL geaendert hat - Fetch ist billig, siehe
# fetch_favicon()) - alte Icon-Datei wird durch die neue ID-basierte
@@ -2776,10 +2857,10 @@ def update_webapp(webapp_id: str, name: str, url: str, sort_order: int):
"""
UPDATE workspace_webapps
SET name = %s, url = %s, sort_order = %s, icon_filename = %s,
icon_content_type = %s, updated_at = now()
icon_content_type = %s, kategorie_id = %s, kiosk_modus = %s, updated_at = now()
WHERE id = %s
""",
(name, url, sort_order, icon_filename, icon_content_type, webapp_id),
(name, url, sort_order, icon_filename, icon_content_type, kategorie_id, kiosk_modus, webapp_id),
)
return {"id": webapp_id, "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
@@ -4287,7 +4368,13 @@ def create_workspace_webapp(workspace_id: str, payload: CreateWebappRequest, aut
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapp": create_webapp(workspace_id, payload.name, payload.url, payload.sort_order)}
return {
"success": True,
"webapp": create_webapp(
workspace_id, payload.name, payload.url, payload.sort_order,
payload.kategorie_id, payload.kiosk_modus,
),
}
@app.put("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")
@@ -4295,7 +4382,13 @@ def update_workspace_webapp(workspace_id: str, webapp_id: str, payload: UpdateWe
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapp": update_webapp(webapp_id, payload.name, payload.url, payload.sort_order)}
return {
"success": True,
"webapp": update_webapp(
webapp_id, payload.name, payload.url, payload.sort_order,
payload.kategorie_id, payload.kiosk_modus,
),
}
@app.delete("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")