Files
HoneyOS/web/js/os.js
T
marcandClaude Opus 5 bf5b7f8e63 Kategorien sind Zeilen in der Liste, kein Feld am Formular
Marcs Vorschlag, und er ist der bessere: "+ kategorie" setzt eine
Ueberschriftenzeile, und was darunter steht, gehoert dazu, bis die naechste
kommt. Die Ueberschrift hat dieselben Pfeile wie eine App, laesst sich
umbenennen und mit x aufloesen.

Damit faellt die Sonderregel von gestern weg -- "am Rand der Gruppe
kategorisiert der Pfeil um, statt zu tauschen". Sie war die Beschreibung
eines Modells; jetzt ist es das Modell: Schiebt man eine App ueber eine
Ueberschrift, ist sie in der anderen Gruppe, und schiebt man eine
Ueberschrift, wandert die Grenze. Nichts davon braucht Code.

Vor allem loest es den echten Mangel der ersten Fassung: Eine Kategorie
umbenennen war ein Feld je App, jetzt ist es eine Zeile.

Gespeichert wird weiter als Feld an der App. Waere die Ueberschrift ein
Eintrag in der App-Liste, enthielte diese Zeilen ohne ID und ohne Adresse,
und jeder Leser muesste das wissen -- Kacheln, Schublade, Pruefung auf
Erreichbarkeit und vor allem die HoneyBar in fremden Apps. Der Preis: Eine
Ueberschrift ohne Apps darunter ueberlebt das Speichern nicht. Eine
Kategorie besteht durch ihre Apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:35:08 +02:00

1360 lines
50 KiB
JavaScript

import {
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, gruppiere, isTouch,
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
SS_BOOTED, state,
} from "./core.js";
import { GLYPHS, glyphSvg } from "./glyphs.js";
import { BIENE_ASCII, BIENE_SVG } from "./biene.js";
import {
widgetPlan, widgetPlanSetzen, widgetsLaden, widgetsOrdnen, widgetsStarten, widgetsZeichnen,
} from "./widgets/rahmen.js";
import "./widgets/user.js";
import { statsHtml } from "./widgets/system.js";
import { loadWeather, renderWeatherBar } from "./widgets/weather.js";
import "./widgets/notiz.js";
import { bildFormularHtml, bildFormularMontieren } from "./widgets/bild.js";
// Die Reihenfolge steht hier und nicht in den Importzeilen darüber: Module
// werden nicht in deren Reihenfolge ausgewertet, sobald eines auf oberster
// Ebene wartet (glyphs.js holt seine Raster mit await). Bis die Reihenfolge
// aus dem Server kommt, ist das ihre Stelle.
widgetsOrdnen(["benutzer", "system", "wetter", "notiz", "bild"]);
/*
* HoneyOS — Desktop-Logik.
*
* Kein Build, kein Framework: die Datei wird so ausgeliefert wie sie hier steht.
* Zustand lebt an genau zwei Stellen — `state.cfg` (kommt aus config.json, die
* beim Containerstart aus der .env entsteht) und `state.settings` (nur Browser). Beides landet in
* localStorage, damit das Gerät sich sein HoneyOS selbst einrichten kann, ohne
* dass etwas neu deployt werden muss.
*
* ZWEI BETRIEBSARTEN, EIN DATENSTAND:
* terminal — Kacheln, Tastatur, alles auf einer Fläche (der ursprüngliche Look)
* desktop — Symbole, verschiebbare Fenster, Taskleiste unten
* Beide zeichnen aus denselben `state.cfg.apps`. Was sich unterscheidet, ist
* ausschließlich die Darstellung — Menüleiste oben, Einstellungen, Palette und
* Erreichbarkeitsprüfung sind in beiden dieselben. Deshalb liegen die
* gemeinsamen Teile hier oben und die beiden Ansichten weiter unten getrennt.
*/
/* ── Laden & Speichern ─────────────────────────────────────────────────── */
/** Holt Nutzer, Einstellungen und Apps. Wirft bei fehlender Anmeldung. */
async function loadFromServer() {
const me = await api("/api/me");
state.me = { sub: me.sub, name: me.name, picture: me.picture };
if (me.version) state.cfg.version = me.version;
state.oidc = !!me.oidc;
state.cfg.user = me.name || "";
// Der Server hat das letzte Wort; localStorage war nur der Vorschuss.
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}), ...(me.settings || {}) };
state.cfg.apps = (await api("/api/apps")).apps;
}
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
* aufblitzt und dann umspringt. */
/* ── Boot ──────────────────────────────────────────────────────────────── */
function bootLines() {
const apps = state.cfg.apps;
return [
{ t: `HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`, d: 90 },
{ t: "", d: 40 },
{ t: "prüfe speicher".padEnd(28, ".") + " 640K ok", d: 120 },
{ t: "prüfe anmeldung".padEnd(28, ".") + " " + (state.cfg.user || "—"), d: 120 },
...apps.map((a) => ({ t: `binde ${a.id} ein`.padEnd(28, ".") + " [ ok ]", d: 140 })),
{ t: "", d: 60 },
{ t: `starte ${state.settings.mode}-modus`.padEnd(28, ".") + " bereit.", d: 200 },
];
}
function runBoot() {
return new Promise((resolve) => {
const log = $("bootLog");
const el = $("boot");
// Über dem Protokoll die Biene — auch hier aus Schriftzeichen, denn der
// Startlauf ist Text und nichts anderes.
$("bootBiene").textContent = BIENE_ASCII;
el.hidden = false;
const lines = bootLines();
let i = 0;
let done = false;
let timer;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
window.removeEventListener("keydown", finish);
el.removeEventListener("click", finish);
el.hidden = true;
try {
sessionStorage.setItem(SS_BOOTED, "1");
} catch {
/* egal — dann bootet es halt jedes Mal */
}
resolve();
};
const step = () => {
if (i >= lines.length) {
timer = setTimeout(finish, 420);
return;
}
const line = lines[i++];
log.textContent += line.t + "\n";
log.classList.add("caret");
timer = setTimeout(step, line.d);
};
window.addEventListener("keydown", finish);
el.addEventListener("click", finish);
step();
});
}
/* ── Gemeinsames: Menüleiste, Uhr, Stand, Start ────────────────────────── */
function renderShell() {
$("desktop").hidden = false;
// Das Wappen steht im Markup, nicht als Bilddatei — dieselbe Zeile wie in
// der HoneyBar fremder Apps, damit beide Leisten wirklich dasselbe zeigen.
$("logoBiene").innerHTML = BIENE_SVG;
renderMenubarApps();
renderMode();
tickClock();
// Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des
// Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt,
// in dem der Server seinen Zwischenspeicher erneuert.
// Nicht abgewartet: Die Widgets holen ihre Daten von außen und dürfen den
// Aufbau des Schreibtischs nicht verzögern. Den Takt fürs Nachladen kennt
// jedes Widget selbst.
widgetsLaden().then(renderWeatherBar);
widgetsStarten();
}
function renderMenubarApps() {
const nav = $("mbApps");
nav.innerHTML = "";
// Derselbe Schalter wie in der HoneyBar fremder Apps: Wer die Symbole dort
// abstellt, will sie hier auch nicht — es ist dieselbe Leiste.
const mitSymbol = state.settings.barGlyphs !== "off";
state.cfg.apps.forEach((app) => {
const b = document.createElement("button");
b.className = "mb-app";
if (mitSymbol) {
const ico = document.createElement("span");
ico.className = "mb-ico";
ico.innerHTML = glyphSvg(app.glyph);
b.appendChild(ico);
}
b.appendChild(document.createTextNode(app.name));
b.title = `${app.name} öffnen — ${app.url}`;
b.addEventListener("click", () => launch(app));
nav.appendChild(b);
});
}
/** Schaltet zwischen den Betriebsarten um. Beide Ansichten werden bei jedem
* Wechsel frisch gezeichnet — offene Fenster überleben ihn bewusst nicht. */
function renderMode() {
const desktopMode = state.settings.mode === "desktop";
$("stage").hidden = desktopMode;
$("statusbar").hidden = desktopMode;
$("deskspace").hidden = !desktopMode;
$("taskbar").hidden = !desktopMode;
if (desktopMode) {
renderDeskIcons();
// Ohne offenes Fenster wäre der Schreibtisch leer — das Systemfenster
// ist der sinnvollste erste Anblick. Am Handy nicht: dort deckt jedes
// Fenster sofort die halbe Symbolspalte zu.
if (!Object.keys(state.wins).length && window.innerWidth > 620) openSystemWindow();
updateTaskbar();
} else {
closeAllWindows();
renderTiles();
}
widgetsZeichnen();
paintProbes();
}
function setMode(mode) {
if (state.settings.mode === mode) return;
state.settings.mode = mode;
saveSettings();
renderMode();
}
function launch(app) {
if (state.settings.openIn === "same") {
window.location.href = app.url;
} else {
window.open(app.url, "_blank", "noopener");
}
}
/** Die Kennzahlen stehen in beiden Ansichten — deshalb über die Klasse in
* alle vorhandenen Listen schreiben, nicht in eine bestimmte id. */
/** Zeichnet den Systemzustand überall neu, wo er gerade steht: als Widget in
* der Spalte und, im Desktop-Modus, im Fenster aus dem Startmenü. */
function renderStats() {
widgetsZeichnen();
document.querySelectorAll("#windows .statlist").forEach((dl) => {
dl.outerHTML = statsHtml();
});
}
function tickClock() {
const now = new Date();
const hm = now.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const date = now.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
$("clock").textContent = hm;
$("sbRight").textContent = date;
$("taskDate").textContent = date;
}
/* ── Erreichbarkeit ────────────────────────────────────────────────────────
* Fremde Domain heißt: die Antwort ist undurchsichtig, wir sehen keinen Status.
* Mehr als "die Kiste hat geantwortet" ist von hier aus nicht zu holen — für
* eine Lampe auf der Kachel reicht das, ein Health-Check ist es nicht.
*/
async function probeApps() {
await Promise.all(
state.cfg.apps.map(async (app) => {
state.probes[app.id] = await probe(app.url);
})
);
paintProbes();
renderStats();
}
async function probe(url) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 5000);
try {
await fetch(url, { mode: "no-cors", cache: "no-store", signal: ctl.signal, redirect: "follow" });
return "up";
} catch {
return "down";
} finally {
clearTimeout(t);
}
}
const STATE_TEXT = { up: "online", down: "keine antwort", unknown: "prüfe …" };
function stateLine(appId) {
const s = state.probes[appId] || "unknown";
return { s, text: STATE_TEXT[s] };
}
function paintProbes() {
document.querySelectorAll(".tile-state").forEach((el) => {
const { s, text } = stateLine(el.dataset.app);
el.dataset.state = s;
el.innerHTML = `<i class="led"></i>${escapeHtml(text)}`;
});
document.querySelectorAll(".deskicon[data-app]").forEach((el) => {
const { s, text } = stateLine(el.dataset.app);
el.dataset.state = s;
el.title = `${el.dataset.name} — ${text}`;
});
}
/* ══ Ansicht 1: Terminal-Modus ═══════════════════════════════════════════ */
function renderTiles() {
const wrap = $("tiles");
wrap.innerHTML = "";
const kachel = (app) => {
const a = document.createElement("a");
a.className = "tile";
a.href = app.url;
a.target = state.settings.openIn === "tab" ? "_blank" : "_self";
a.rel = "noopener";
a.innerHTML = `
<span class="tile-icon">${glyphSvg(app.glyph)}</span>
<span class="tile-name">${escapeHtml(app.name)}</span>
<span class="tile-sub">${escapeHtml(app.subtitle || "")}</span>
<span class="tile-state" data-app="${app.id}" data-state="unknown"><i class="led"></i>prüfe …</span>`;
a.addEventListener("click", (e) => {
e.preventDefault();
launch(app);
});
return a;
};
// Gruppen bekommen eine Überschrift, die namenlose erste nicht: Ohne
// Kategorien sieht die Fläche aus wie immer, nur mit ihnen entsteht Struktur.
//
// Die Überschrift bleibt ein Kind desselben Rasters und spannt über alle
// Spalten (CSS: .tiles-kopf). Ein eigenes Raster je Gruppe haette jede
// Gruppe ihre Spalten selbst bestimmen lassen — dann stehen die Kacheln
// zweier Gruppen nicht mehr untereinander.
const gruppen = gruppiere(state.cfg.apps);
gruppen.forEach((g) => {
if (g.kategorie) {
const h = document.createElement("h2");
h.className = "tiles-kopf";
h.textContent = g.kategorie;
wrap.appendChild(h);
}
g.apps.forEach((app) => wrap.appendChild(kachel(app)));
});
const add = document.createElement("button");
add.className = "tile tile-add";
add.innerHTML = state.cfg.apps.length
? "<span>+ app hinzufügen</span>"
: "<span>Noch keine App.<br />Hier die erste anlegen.</span>";
add.addEventListener("click", openApps);
wrap.appendChild(add);
}
/* ══ Ansicht 2: Desktop-Modus ════════════════════════════════════════════ */
function renderDeskIcons() {
const wrap = $("deskIcons");
wrap.innerHTML = "";
const items = [
...state.cfg.apps.map((app) => ({
id: app.id,
name: app.name,
glyph: app.glyph,
app,
run: () => launch(app),
})),
{ id: "programme", name: "Programme", glyph: "folder", run: openProgramsWindow },
{ id: "system", name: "System", glyph: "chip", run: openSystemWindow },
{ id: "settings", name: "Einstellungen", glyph: "gear", run: openSettings },
];
items.forEach((item) => {
const b = document.createElement("button");
b.className = "deskicon";
b.dataset.name = item.name;
if (item.app) b.dataset.app = item.app.id;
b.innerHTML = `<span class="di-glyph">${glyphSvg(item.glyph)}</span><span class="di-label">${escapeHtml(item.name)}</span>`;
// Am Zeigegerät wie früher: einmal auswählen, zweimal öffnen.
// Am Finger wäre das eine Zumutung — dort öffnet die erste Berührung.
b.addEventListener("click", () => {
selectIcon(b);
if (isTouch()) item.run();
});
b.addEventListener("dblclick", item.run);
b.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
item.run();
}
});
wrap.appendChild(b);
});
wrap.addEventListener("pointerdown", (e) => {
if (e.target === wrap) selectIcon(null);
});
}
function selectIcon(el) {
document.querySelectorAll(".deskicon[data-sel]").forEach((i) => delete i.dataset.sel);
if (el) el.dataset.sel = "1";
}
/* ── Fenster ───────────────────────────────────────────────────────────── */
/**
* Legt ein Fenster an oder holt ein vorhandenes nach vorn.
* `body` ist fertiges HTML, `onMount` bekommt den Rumpf zum Verdrahten.
*/
function openWindow({ id, title, body, w = 340, x, y, onMount }) {
const existing = state.wins[id];
if (existing) {
existing.min = false;
delete existing.el.dataset.min;
focusWindow(id);
updateTaskbar();
return existing.el;
}
const layer = $("windows");
const bounds = layer.getBoundingClientRect();
const width = Math.min(w, Math.max(200, bounds.width - 24));
const count = Object.keys(state.wins).length;
const el = document.createElement("section");
el.className = "dwin";
el.style.width = width + "px";
// Links liegt die Symbolspalte — Fenster starten rechts davon, sonst deckt
// das erste Fenster gleich die App-Symbole zu.
const startX = bounds.width > 420 ? 124 : 8;
el.style.left = (x ?? Math.min(startX + count * 26, Math.max(8, bounds.width - width - 8))) + "px";
el.style.top = (y ?? 18 + count * 24) + "px";
el.innerHTML = `
<div class="win-bar">
<span class="win-title">${escapeHtml(title)}</span>
<span class="win-btns">
<button class="win-btn" data-act="min" title="In die Taskleiste">&minus;</button>
<button class="win-btn" data-act="close" title="Schließen">&times;</button>
</span>
</div>
<div class="win-body">${body}</div>`;
layer.appendChild(el);
state.wins[id] = { el, min: false, title };
el.querySelector('[data-act="close"]').addEventListener("click", () => closeWindow(id));
el.querySelector('[data-act="min"]').addEventListener("click", () => minimizeWindow(id));
el.addEventListener("pointerdown", () => focusWindow(id));
makeDraggable(el, el.querySelector(".win-bar"), layer);
if (onMount) onMount(el.querySelector(".win-body"));
focusWindow(id);
updateTaskbar();
return el;
}
function focusWindow(id) {
Object.entries(state.wins).forEach(([wid, w]) => (w.el.dataset.focus = wid === id ? "1" : "0"));
const w = state.wins[id];
if (w) w.el.style.zIndex = String(++state.zTop);
updateTaskbar();
}
function closeWindow(id) {
const w = state.wins[id];
if (!w) return;
w.el.remove();
delete state.wins[id];
updateTaskbar();
}
function closeAllWindows() {
Object.keys(state.wins).forEach(closeWindow);
}
function minimizeWindow(id) {
const w = state.wins[id];
if (!w) return;
w.min = true;
w.el.dataset.min = "1";
updateTaskbar();
}
/** Ziehen am Titelbalken. Pointer Events, damit Maus und Finger denselben
* Weg nehmen; die Position wird im sichtbaren Bereich gehalten. */
function makeDraggable(win, bar, layer) {
bar.addEventListener("pointerdown", (e) => {
if (e.target.closest(".win-btn")) return;
const wr = win.getBoundingClientRect();
const lr = layer.getBoundingClientRect();
const offX = e.clientX - wr.left;
const offY = e.clientY - wr.top;
win.dataset.drag = "1";
bar.setPointerCapture(e.pointerId);
const move = (ev) => {
const x = Math.max(0, Math.min(ev.clientX - lr.left - offX, lr.width - wr.width));
const y = Math.max(0, Math.min(ev.clientY - lr.top - offY, lr.height - 26));
win.style.left = x + "px";
win.style.top = y + "px";
};
const up = () => {
delete win.dataset.drag;
bar.removeEventListener("pointermove", move);
bar.removeEventListener("pointerup", up);
bar.removeEventListener("pointercancel", up);
};
bar.addEventListener("pointermove", move);
bar.addEventListener("pointerup", up);
bar.addEventListener("pointercancel", up);
});
}
function updateTaskbar() {
const list = $("taskList");
if (!list) return;
list.innerHTML = "";
Object.entries(state.wins).forEach(([id, w]) => {
const b = document.createElement("button");
b.className = "task-btn";
b.textContent = w.title;
b.dataset.active = !w.min && w.el.dataset.focus === "1" ? "1" : "0";
b.addEventListener("click", () => {
if (w.min) {
w.min = false;
delete w.el.dataset.min;
focusWindow(id);
} else if (w.el.dataset.focus === "1") {
minimizeWindow(id);
} else {
focusWindow(id);
}
});
list.appendChild(b);
});
}
/* ── Fensterinhalte ────────────────────────────────────────────────────── */
function openSystemWindow() {
openWindow({
id: "system",
title: "system",
w: 320,
// Derselbe Inhalt wie das Widget im Terminal-Modus — eine Quelle, damit
// nicht zwei Listen mit denselben Zahlen auseinanderlaufen.
body: statsHtml(),
});
}
function openProgramsWindow() {
const rows = gruppiere(state.cfg.apps)
.map(
(g) =>
(g.kategorie ? `<div class="al-kopf">${escapeHtml(g.kategorie)}</div>` : "") +
g.apps
.map(
(a) => `<button data-launch="${a.id}">
<span class="al-glyph">${glyphSvg(a.glyph)}</span>
<span>${escapeHtml(a.name)}</span>
<span class="al-sub">${escapeHtml(a.subtitle || "")}</span>
</button>`
)
.join("")
)
.join("");
openWindow({
id: "programme",
title: "programme",
w: 360,
body: `<div class="applist">${rows}
<button data-settings="1"><span class="al-glyph">${glyphSvg("gear")}</span><span>programme verwalten …</span></button>
</div>`,
onMount: (root) => {
root.querySelectorAll("[data-launch]").forEach((b) =>
b.addEventListener("click", () => {
const app = state.cfg.apps.find((a) => a.id === b.dataset.launch);
if (app) launch(app);
})
);
root.querySelector("[data-settings]").addEventListener("click", openApps);
},
});
}
/* ── Dialoge: Modal im Terminal, Fenster auf dem Schreibtisch ──────────── */
function openPanel(id, title, html, onMount, width) {
if (state.settings.mode === "desktop") {
openWindow({ id, title, body: html, w: width || 380, onMount });
return;
}
$("modalTitle").textContent = title;
$("modalBody").innerHTML = html;
$("modal").hidden = false;
if (onMount) onMount($("modalBody"));
}
function closeModal() {
$("modal").hidden = true;
}
function openSettings() {
const s = state.settings;
/* Ein Feld je Zeile, Beschriftung links, Schalter rechts — und die Zeilen in
* Abschnitten. Vorher stand alles untereinander in einer einzigen Spalte,
* mit Abstandshaltern zwischen den Schaltern; bei zwölf Einstellungen sieht
* das aus wie eine Liste, in der man suchen muss. */
const seg = (name, opts, cur) =>
`<div class="seg" data-seg="${name}">` +
opts.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${cur === v}">${l}</button>`).join("") +
`</div>`;
const zeile = (label, rechts) => `<div class="reihe"><span class="r-label">${label}</span>${rechts}</div>`;
const schalter = (name, label, anLabel = "an") =>
zeile(label, seg(name, [["on", anLabel], ["off", "aus"]], s[name]));
const sektion = (titel, inhalt) => `<section class="sek"><h4 class="sek-titel">${titel}</h4>${inhalt}</section>`;
openPanel(
"settings",
"einstellungen",
sektion(
"Darstellung",
zeile("Betriebsart", seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)) +
zeile("Röhre", seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)) +
schalter("scanlines", "Scanlines") +
schalter("flicker", "Flackern") +
schalter("glow", "Glühen")
) +
sektion(
"HoneyBar",
zeile("Symbole im App-Streifen", seg("barGlyphs", [["on", "an"], ["off", "nur namen"]], s.barGlyphs)) +
zeile("Apps öffnen", seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)) +
schalter("weatherBar", "Wetter in der Leiste") +
`<p class="sek-hinweis">Gilt auch für die HoneyBar in deinen Apps — es ist dieselbe Leiste.</p>`
) +
sektion(
"Widgets",
`<ul class="wl" id="wlListe"></ul>
<p class="sek-hinweis">Reihenfolge und An/Aus liegen bei deinem Konto, nicht am Gerät.</p>`
) +
sektion(
"Bild",
bildFormularHtml() +
`<p class="sek-hinweis">Genau ein Bild je Konto; ein neues ersetzt das alte.
Zu sehen ist es im Widget „bild".</p>`
) +
sektion(
"Wetter",
`<div class="wx-city-row">
<input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" />
<button class="btn" id="btnWxSearch">suchen</button>
</div>
<div id="wxResults" class="wx-results"></div>
<p class="sek-hinweis" id="wxCurrent"></p>`
) +
sektion(
"Programme",
zeile("Eingetragene Apps", `<button class="btn" id="btnApps">${state.cfg.apps.length} — verwalten …</button>`)
) +
sektion(
"Konto",
`<p class="sek-hinweis">Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.</p>` +
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus
// einer fremden App heraus nicht anklicken können soll (siehe
// honeybar.js), und von HoneyOS aus findet man es dort, wo alles
// andere auch steht.
(state.oidc ? `<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>` : "")
),
(root) => {
root.querySelectorAll(".seg").forEach((segEl) => {
segEl.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-v]");
if (!btn) return;
const key = segEl.dataset.seg;
segEl.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
if (key === "mode") {
// Der Moduswechsel zeichnet alles neu — die Einstellungen sollen
// dabei offen bleiben, also anschließend erneut öffnen.
setMode(btn.dataset.v);
openSettings();
return;
}
state.settings[key] = btn.dataset.v;
saveSettings();
renderStats();
if (key.startsWith("weather")) loadWeather();
if (key === "openIn" && state.settings.mode === "terminal") renderTiles();
if (key === "barGlyphs") renderMenubarApps();
});
});
widgetListeZeichnen(root.querySelector("#wlListe"));
bildFormularMontieren(root);
const zeigeOrt = () => {
root.querySelector("#wxCurrent").textContent = state.settings.weatherCity
? `eingestellt: ${state.settings.weatherCity}`
: "kein Ort eingestellt — ohne Ort bleibt das Wetter aus.";
};
zeigeOrt();
const suchen = async () => {
const q = root.querySelector("#wxQuery").value.trim();
const out = root.querySelector("#wxResults");
if (!q) return;
out.textContent = "suche …";
let orte;
try {
orte = await api("/api/weather/search?q=" + encodeURIComponent(q));
} catch {
out.textContent = "Ortssuche nicht erreichbar.";
return;
}
if (!orte || !orte.length) {
out.textContent = "nichts gefunden.";
return;
}
out.innerHTML = orte
.map((o, i) => `<button type="button" class="wx-hit" data-i="${i}">${o.name}<span>${o.land}</span></button>`)
.join("");
out.querySelectorAll(".wx-hit").forEach((b) =>
b.addEventListener("click", () => {
const o = orte[Number(b.dataset.i)];
// Koordinaten mitspeichern: Sonst müsste jede Wetterabfrage den Ort
// erneut suchen — zwei Anfragen nach draußen statt einer.
state.settings.weatherCity = o.name;
state.settings.weatherLat = String(o.lat);
state.settings.weatherLon = String(o.lon);
saveSettings();
out.innerHTML = "";
root.querySelector("#wxQuery").value = "";
zeigeOrt();
loadWeather();
})
);
};
root.querySelector("#btnWxSearch").addEventListener("click", suchen);
root.querySelector("#wxQuery").addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); suchen(); }
});
root.querySelector("#btnApps").addEventListener("click", openApps);
root.querySelector("#btnLogout")?.addEventListener("click", logout);
},
420
);
}
/* ── Die Widget-Liste in den Einstellungen ─────────────────────────────────
*
* Dieselbe Bedienung wie bei den Apps: Pfeile statt Ziehen. Ziehen wäre hübsch,
* aber am Handy ist es fummelig, und die Liste ist kurz.
*
* Der Rahmen kennt die Widgets, nicht diese Datei — hier wird nur gezeigt, was
* er hergibt, und zurückgegeben, was der Nutzer daraus gemacht hat.
*/
function widgetListeZeichnen(ul) {
if (!ul) return;
const plan = widgetPlan();
ul.innerHTML = plan
.map(
(p, i) => `
<li class="wl-zeile${p.an ? "" : " wl-aus"}">
<button type="button" class="wl-an" data-an="${i}" aria-pressed="${p.an}">${p.an ? "an" : "aus"}</button>
<span class="wl-name">${escapeHtml(p.titel)}${
p.wo && p.wo.length === 1 ? `<em>nur ${p.wo[0]}</em>` : ""
}</span>
<span class="ae-move">
<button type="button" class="ae-arrow" data-hoch="${i}" ${i === 0 ? "disabled" : ""}>&#9650;</button>
<button type="button" class="ae-arrow" data-runter="${i}" ${
i === plan.length - 1 ? "disabled" : ""
}>&#9660;</button>
</span>
</li>`
)
.join("");
const uebernehmen = () => {
widgetPlanSetzen(plan);
widgetListeZeichnen(ul);
};
ul.querySelectorAll("[data-an]").forEach((b) =>
b.addEventListener("click", () => {
const p = plan[Number(b.dataset.an)];
p.an = !p.an;
uebernehmen();
})
);
const tausche = (i, j) => {
[plan[i], plan[j]] = [plan[j], plan[i]];
uebernehmen();
};
ul.querySelectorAll("[data-hoch]").forEach((b) =>
b.addEventListener("click", () => tausche(Number(b.dataset.hoch), Number(b.dataset.hoch) - 1))
);
ul.querySelectorAll("[data-runter]").forEach((b) =>
b.addEventListener("click", () => tausche(Number(b.dataset.runter), Number(b.dataset.runter) + 1))
);
}
/* ── App-Verwaltung ────────────────────────────────────────────────────────
* Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als
* Ganzes zum Server. Der prüft sie noch einmal und antwortet mit dem, was er
* wirklich behalten hat — das ist danach der Stand, nicht das Formular.
*//* Bearbeitet wird eine Liste aus zweierlei Zeilen: Apps und Überschriften.
*
* Das ist bewusst **nicht** das, was gespeichert wird — gespeichert ist eine
* Kategorie als Feld an jeder App. Die Überschrift gibt es nur hier, weil sie
* hier das ehrlichere Bild ist: Man sieht, was man sortiert, und das Umhängen
* einer App braucht keine Sonderregel, es ergibt sich daraus, dass sie unter
* einer anderen Überschrift landet.
*
* In der App-Liste selbst hätten Überschriften nichts zu suchen: Dann enthielte
* sie Einträge ohne ID und ohne Adresse, und jeder Leser müsste das wissen —
* die Kachelfläche, die Schublade, die Erreichbarkeitsprüfung und vor allem
* die HoneyBar in fremden Apps, die mit so wenig wie möglich auskommen soll.
*
* Was oberhalb der ersten Überschrift steht, hat keine Kategorie.
*/
function openApps() {
const KOPF = "kopf";
// Aus der gespeicherten Liste die Zeilen bauen: je Gruppe eine Überschrift,
// darunter ihre Apps. Die Gruppe ohne Kategorie steht oben und bekommt keine.
const zeilenAus = (apps) =>
gruppiere(apps).flatMap((g) => [
...(g.kategorie ? [{ typ: KOPF, name: g.kategorie }] : []),
...g.apps.map((a) => ({ ...a })),
]);
// Und zurück: durchlaufen, die zuletzt gesehene Überschrift mitschreiben.
// Eine Überschrift ohne Apps darunter verschwindet dabei — eine Kategorie
// besteht nur durch die Apps, die sie tragen.
const appsAus = (zeilen) => {
let kategorie = "";
const out = [];
for (const z of zeilen) {
if (z.typ === KOPF) {
kategorie = z.name.trim();
continue;
}
const { __offen, ...rest } = z;
out.push({ ...rest, kategorie });
}
return out;
};
let zeilen = zeilenAus(state.cfg.apps);
// Die Auswahl kommt aus glyphs.json selbst — wer dort ein Symbol ergänzt,
// kann es sofort wählen. Die Wettersymbole liegen in derselben Datei (die
// HoneyBar braucht beides in einer Antwort), taugen aber nicht als App-Logo.
const WETTER_GLYPHEN = ["sonne", "heiter", "wolke", "nebel", "regen", "schnee", "gewitter"];
const GLYPHS_LIST = Object.keys(GLYPHS).filter((g) => !WETTER_GLYPHEN.includes(g));
const pfeile = (i, was) => `
<span class="ae-move">
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
title="nach oben" aria-label="${escapeAttr(was)} nach oben">&#9650;</button>
<button class="ae-arrow" data-down="${i}" ${i === zeilen.length - 1 ? "disabled" : ""}
title="nach unten" aria-label="${escapeAttr(was)} nach unten">&#9660;</button>
</span>`;
const kopfHtml = (z, i) => `
<div class="ae-gruppe">
<input class="ae-kat" data-k="${i}" value="${escapeAttr(z.name)}"
placeholder="kategorie" aria-label="Name der Kategorie" />
<button class="ae-kat-weg" data-kdel="${i}" title="Kategorie auflösen"
aria-label="Kategorie ${escapeAttr(z.name)} auflösen">&times;</button>
${pfeile(i, z.name || "kategorie")}
</div>`;
const rowHtml = (a, i) => `
<details class="approw" ${a.__offen ? "open" : ""}>
<summary>
<span class="ae-glyph">${glyphSvg(a.glyph)}</span>
<span>${escapeHtml(a.name || a.id || "neue app")}</span>
<span class="ae-url">${escapeHtml(a.url || "")}</span>
${pfeile(i, a.name || a.id)}
</summary>
<div class="ae-body">
<label class="ae-field"><span>ID</span>
<input data-i="${i}" data-f="id" value="${escapeAttr(a.id || "")}" placeholder="aether" spellcheck="false" /></label>
<p class="ae-hint">klein, ohne Leerzeichen — muss zu <code>data-app</code> der HoneyBar passen</p>
<label class="ae-field"><span>Name</span>
<input data-i="${i}" data-f="name" value="${escapeAttr(a.name || "")}" placeholder="AETHER" /></label>
<label class="ae-field"><span>Untertitel</span>
<input data-i="${i}" data-f="subtitle" value="${escapeAttr(a.subtitle || "")}" placeholder="Cloud &amp; Dateien" /></label>
<label class="ae-field"><span>Adresse</span>
<input data-i="${i}" data-f="url" value="${escapeAttr(a.url || "")}" placeholder="https://…" spellcheck="false" /></label>
<label class="ae-field"><span>Symbol</span>
<select data-i="${i}" data-f="glyph">
${GLYPHS_LIST.map((g) => `<option value="${g}" ${a.glyph === g ? "selected" : ""}>${g}</option>`).join("")}
</select></label>
<div class="ae-actions">
<button class="btn btn-danger" data-del="${i}">löschen</button>
</div>
</div>
</details>`;
const render = (root) => {
const list = root.querySelector("#appList");
list.innerHTML = zeilen.length
? zeilen.map((z, i) => (z.typ === KOPF ? kopfHtml(z, i) : rowHtml(z, i))).join("")
: `<div class="apped-empty">Noch keine App eingetragen.<br />Mit „+ neue app" die erste anlegen.</div>`;
list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
inp.addEventListener("input", () => {
zeilen[Number(inp.dataset.i)][inp.dataset.f] = inp.value;
});
});
// Umbenennen ist hier eine Zeile — das ist der Grund für diese Bauart.
// Neu gezeichnet wird dabei nicht, sonst verlöre das Feld den Cursor.
list.querySelectorAll("input[data-k]").forEach((inp) => {
inp.addEventListener("input", () => {
zeilen[Number(inp.dataset.k)].name = inp.value;
});
});
list.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => {
zeilen.splice(Number(b.dataset.del), 1);
render(root);
})
);
// Eine Überschrift löschen heißt: Ihre Apps rutschen in die Gruppe darüber.
// Die Apps selbst anzufassen wäre falsch — aufgelöst wird eine Kategorie,
// nicht das, was in ihr steht.
list.querySelectorAll("[data-kdel]").forEach((b) =>
b.addEventListener("click", () => {
zeilen.splice(Number(b.dataset.kdel), 1);
render(root);
})
);
// Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne
// das klappte dabei alles zu, was man gerade bearbeitet.
list.querySelectorAll(".approw").forEach((d) => {
const i = Number(d.querySelector("[data-up]").dataset.up);
d.addEventListener("toggle", () => {
if (zeilen[i]) zeilen[i].__offen = d.open;
});
});
// Die Pfeile sitzen im <summary>: Ohne preventDefault klappt jeder
// Sortierklick die Zeile auf oder zu. Getauscht wird stumpf Nachbar gegen
// Nachbar — dass eine App dabei die Gruppe wechselt oder eine ganze Gruppe
// wandert, ergibt sich von selbst und braucht keine eigene Regel.
const tausche = (i, j) => {
if (j < 0 || j >= zeilen.length) return;
[zeilen[i], zeilen[j]] = [zeilen[j], zeilen[i]];
render(root);
// Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken
// kann, ohne die Maus nachzuführen.
const wieder = list.querySelector(`[data-${j < i ? "up" : "down"}="${j}"]`);
if (wieder && !wieder.disabled) wieder.focus();
};
list.querySelectorAll("[data-up], [data-down]").forEach((b) =>
b.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const i = Number(b.dataset.up ?? b.dataset.down);
tausche(i, "up" in b.dataset ? i - 1 : i + 1);
})
);
};
openPanel(
"apps",
"programme verwalten",
`<div class="apped"><div id="appList"></div>
<div class="modal-actions">
<span class="saveline" id="appMsg"></span>
<button class="btn" id="btnKat">+ kategorie</button>
<button class="btn" id="btnAdd">+ neue app</button>
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
</div>
</div>`,
(root) => {
render(root);
root.querySelector("#btnAdd").addEventListener("click", () => {
// Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen.
zeilen.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
render(root);
});
root.querySelector("#btnKat").addEventListener("click", () => {
zeilen.push({ typ: KOPF, name: "" });
render(root);
[...root.querySelectorAll(".ae-kat")].pop()?.focus();
});
root.querySelector("#btnSaveApps").addEventListener("click", async () => {
const msg = root.querySelector("#appMsg");
msg.dataset.kind = "";
msg.textContent = "speichere …";
try {
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(appsAus(zeilen)) })).apps;
const offen = new Set(zeilen.filter((z) => z.__offen).map((z) => z.id));
zeilen = zeilenAus(state.cfg.apps).map((z) =>
z.typ === KOPF ? z : { ...z, __offen: offen.has(z.id) }
);
msg.textContent = `${state.cfg.apps.length} gespeichert.`;
render(root);
renderMenubarApps();
renderMode();
probeApps();
} catch (e) {
msg.dataset.kind = "err";
msg.textContent = e.message;
}
});
},
460
);
}
function openAbout() {
openPanel(
"about",
"über honeyos",
`<div class="about">
<p><b>HoneyOS ${escapeHtml(state.cfg.version)}</b> — eine Oberfläche für mehrere eigene Apps.
Jede App bleibt ihre eigene Seite auf ihrer eigenen Domain; HoneyOS ist der Schreibtisch
davor und die HoneyBar darin.</p>
<p>Zwei Betriebsarten, ein Datenstand: <code>terminal</code> zeigt Kacheln,
<code>desktop</code> Symbole und Fenster. Die HoneyBar oben ist in beiden dieselbe —
und dieselbe steckt per <code>honeybar/honeybar.js</code> auch in Aether und InkNote.</p>
<dl class="keys">
<dt>M</dt><dd>Betriebsart wechseln</dd>
<dt>S</dt><dd>Einstellungen</dd>
<dt>?</dt><dd>diese Hilfe</dd>
<dt>Esc</dt><dd>Fenster schließen</dd>
</dl>
</div>`
);
}
/* ── Hilfsmittel ───────────────────────────────────────────────────────── */
/* ── App-Menü im Pfeil rechts ──────────────────────────────────────────────
* Der Zwilling des Pfeils in honeybar.js. Die Apps stehen zwar schon als
* Streifen in der Leiste, aber der schiebt bei vielen Apps seitwärts — das
* Menü zeigt sie alle untereinander. Das Zahnrad ist dafür weggefallen:
* Einstellungen stehen im Systemmenü links, wie in jeder App.
*/
function buildAppMenu() {
const menu = document.createElement("div");
menu.className = "startmenu appmenu";
menu.id = "appmenu";
$("desktop").appendChild(menu);
const btn = $("btnAppMenu");
btn.addEventListener("click", (e) => {
e.stopPropagation();
if (menu.dataset.open === "1") return closeAppMenu();
closeSystemMenu();
closeStartMenu();
// Dieselbe Schublade wie in honeybar.js: Symbol, Name, Untertitel. Sie wird
// absichtlich aufgemacht, weil man etwas sucht — also wird gelesen, nicht
// nur wiedererkannt. Im Streifen oben reicht ein Blick, dort ist der Name
// abschaltbar; hier steht er immer.
menu.innerHTML =
`<div class="sm-head">wechseln zu</div>` +
(state.cfg.apps.length
? `<div class="drawer">` +
gruppiere(state.cfg.apps)
.map(
(g) =>
(g.kategorie ? `<div class="d-kopf">${escapeHtml(g.kategorie)}</div>` : "") +
g.apps
.map(
(a) =>
`<button class="d-app" data-app="${escapeAttr(a.id)}">
<span class="d-ico">${glyphSvg(a.glyph)}</span>
<span class="d-name">${escapeHtml(a.name)}</span>
${a.subtitle ? `<span class="d-sub">${escapeHtml(a.subtitle)}</span>` : ""}
</button>`
)
.join("")
)
.join("") +
`</div>`
: `<button data-settings="1">keine app eingetragen …</button>`);
menu.querySelectorAll("[data-app]").forEach((b) =>
b.addEventListener("click", () => {
const app = state.cfg.apps.find((a) => a.id === b.dataset.app);
closeAppMenu();
if (app) launch(app);
})
);
menu.querySelector("[data-settings]")?.addEventListener("click", () => {
closeAppMenu();
openApps();
});
menu.dataset.open = "1";
btn.setAttribute("aria-expanded", "true");
});
document.addEventListener("click", closeAppMenu);
}
function closeAppMenu() {
const menu = $("appmenu");
if (!menu) return;
menu.dataset.open = "0";
$("btnAppMenu").setAttribute("aria-expanded", "false");
}
/* ── Systemmenü im Logo ────────────────────────────────────────────────────
* Der Zwilling des Menüs in honeybar.js: gleiche Punkte, gleiche Reihenfolge.
* Hier sind es Handlungen, dort Links hierher — denn nur hier ist man
* angemeldet. Wer aus Aether auf „Einstellungen“ klickt, landet auf genau
* demselben Feld, das dieser Punkt aufmacht.
*/
function buildSystemMenu() {
const menu = document.createElement("div");
menu.className = "startmenu sysmenu";
menu.id = "sysmenu";
$("desktop").appendChild(menu);
const btn = $("btnSystem");
btn.addEventListener("click", (e) => {
e.stopPropagation();
if (menu.dataset.open === "1") return closeSystemMenu();
closeStartMenu();
closeAppMenu();
const terminal = state.settings.mode === "terminal";
menu.innerHTML =
`<div class="sm-head">honeyos</div>
<button data-do="about">Über HoneyOS</button>
<button data-do="settings">Einstellungen</button>
<hr />
<button data-do="mode">${terminal ? "▣ Desktop-Modus" : "↩ Terminal-Modus"}</button>`;
menu.querySelectorAll("[data-do]").forEach((b) =>
b.addEventListener("click", () => {
closeSystemMenu();
({
about: openAbout,
settings: openSettings,
mode: () => setMode(terminal ? "desktop" : "terminal"),
})[b.dataset.do]();
})
);
menu.dataset.open = "1";
btn.setAttribute("aria-expanded", "true");
});
document.addEventListener("click", closeSystemMenu);
}
function closeSystemMenu() {
const menu = $("sysmenu");
if (!menu) return;
menu.dataset.open = "0";
$("btnSystem").setAttribute("aria-expanded", "false");
}
/* ── Startmenü ─────────────────────────────────────────────────────────── */
function buildStartMenu() {
const menu = document.createElement("div");
menu.className = "startmenu";
menu.id = "startmenu";
$("desktop").appendChild(menu);
const btn = $("btnStart");
btn.addEventListener("click", (e) => {
e.stopPropagation();
const open = menu.dataset.open === "1";
if (open) return closeStartMenu();
// Zwei offene Menüs sähen aus wie ein Fehler: Der Klick auf den Startknopf
// hält sein Ereignis auf, also erreicht es die Schließer der anderen nicht
// von selbst.
closeSystemMenu();
closeAppMenu();
menu.innerHTML = `<div class="sm-head">starten</div>` +
state.cfg.apps
.map((a) => `<button data-app="${escapeAttr(a.id)}"><span class="sm-ico">${glyphSvg(a.glyph)}</span>${escapeHtml(a.name)}</button>`)
.join("") +
`<hr /><button data-win="programme">Programme</button>
<button data-win="system">System</button>
<button data-win="settings">Einstellungen</button>
<button data-win="about">Über HoneyOS</button>
<hr /><button data-mode="terminal">↩ Terminal-Modus</button>`;
menu.querySelectorAll("[data-app]").forEach((b) =>
b.addEventListener("click", () => {
const app = state.cfg.apps.find((a) => a.id === b.dataset.app);
closeStartMenu();
if (app) launch(app);
})
);
menu.querySelectorAll("[data-win]").forEach((b) =>
b.addEventListener("click", () => {
closeStartMenu();
({ programme: openProgramsWindow, system: openSystemWindow, settings: openSettings, about: openAbout })[
b.dataset.win
]();
})
);
menu.querySelector("[data-mode]").addEventListener("click", () => {
closeStartMenu();
setMode("terminal");
});
menu.dataset.open = "1";
btn.setAttribute("aria-expanded", "true");
});
document.addEventListener("click", closeStartMenu);
}
function closeStartMenu() {
const menu = $("startmenu");
if (!menu) return;
menu.dataset.open = "0";
$("btnStart").setAttribute("aria-expanded", "false");
}
/* ── Start ─────────────────────────────────────────────────────────────── */
/* ── Anmeldeschirm ─────────────────────────────────────────────────────────
* Kein Formular: HoneyOS kennt keine Passwörter. Es gibt genau einen Knopf,
* und der schickt zum Anbieter. Der Rest ist Kulisse — aber Kulisse, die sagt,
* was schiefging, wenn der Rückweg gescheitert ist.
*/
const LOGIN_ERRORS = {
anbieter: "Der Anmeldedienst war nicht erreichbar.",
abgelaufen: "Der Versuch hat zu lange gedauert. Noch einmal.",
state: "Die Antwort passte nicht zur Anfrage. Noch einmal.",
kein_code: "Der Anbieter hat keinen Code geschickt.",
tausch: "Der Code ließ sich nicht einlösen — Client-ID oder Geheimnis prüfen.",
kein_id_token: "Der Anbieter hat kein ID-Token geschickt.",
token: "Das ID-Token war ungültig.",
claims: "Das ID-Token nannte keinen Nutzer.",
nicht_erlaubt: "Dieses Konto steht nicht auf der erlaubten Liste.",
};
function showLogin(pub) {
const el = $("login");
const log = $("loginLog");
el.hidden = false;
const fehler = new URLSearchParams(location.search).get("fehler");
const lines = [
`HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`,
"",
"prüfe sitzung".padEnd(28, ".") + " keine",
"anmeldung erforderlich",
];
log.textContent = lines.join("\n") + "\n";
const note = $("loginNote");
if (fehler) {
note.innerHTML = `<b>${escapeHtml(LOGIN_ERRORS[fehler] || "Die Anmeldung ist gescheitert (" + fehler + ").")}</b>`;
} else {
note.textContent = "Die Anmeldung läuft über deinen Anbieter. HoneyOS speichert kein Passwort.";
}
const btn = $("btnLogin");
$("loginLabel").textContent = pub.buttonLabel || "Anmelden";
btn.addEventListener("click", () => {
btn.disabled = true;
$("loginLabel").textContent = "weiterleiten …";
location.href = "/api/auth/login";
});
btn.focus();
}
async function logout() {
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* egal — die Sitzung ist so oder so nichts mehr wert */
}
try {
sessionStorage.removeItem(SS_BOOTED);
} catch {
/* dann bootet es beim nächsten Mal eben neu */
}
location.href = "/";
}
/* ── Start ─────────────────────────────────────────────────────────────── */
async function main() {
// Einstellungen aus dem letzten Besuch gelten sofort, damit die Röhre beim
// Laden nicht in der falschen Farbe aufflackert.
loadLocalSettings();
applySettings();
let pub = { oidc: false, buttonLabel: "Anmelden" };
try {
pub = await api("/api/public");
if (pub.version) state.cfg.version = pub.version;
} catch {
/* Server nicht erreichbar — der Anmeldeschirm sagt es gleich selbst */
}
try {
await loadFromServer();
} catch {
return showLogin(pub); // nicht angemeldet: hier ist Schluss
}
applySettings(); // jetzt gilt, was der Server gespeichert hat
let skipBoot = false;
try {
skipBoot = sessionStorage.getItem(SS_BOOTED) === "1";
} catch {
/* kein sessionStorage — dann bootet es jedes Mal, auch schön */
}
if (!skipBoot) await runBoot();
renderShell();
buildStartMenu();
buildSystemMenu();
buildAppMenu();
probeApps();
setInterval(tickClock, 15000);
setInterval(probeApps, 120000);
$("btnClose").addEventListener("click", closeModal);
$("modal").addEventListener("click", (e) => {
if (e.target === $("modal")) closeModal();
});
window.addEventListener("keydown", (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
if (e.key === "Escape") {
// Bei offenem Startmenü schließt Esc nur das Menü — sonst nähme es
// einem gleichzeitig das oberste Fenster weg.
const menu = $("startmenu");
if (menu && menu.dataset.open === "1") return closeStartMenu();
const sys = $("sysmenu");
if (sys && sys.dataset.open === "1") return closeSystemMenu();
const apps = $("appmenu");
if (apps && apps.dataset.open === "1") return closeAppMenu();
return state.settings.mode === "desktop" ? closeTopWindow() : closeModal();
}
if (typing) return;
if (e.key === "m" || e.key === "M") setMode(state.settings.mode === "desktop" ? "terminal" : "desktop");
if (e.key === "s" || e.key === "S") openSettings();
if (e.key === "?") openAbout();
});
}
/** Esc schließt im Desktop-Modus das oberste Fenster. */
function closeTopWindow() {
const entries = Object.entries(state.wins).filter(([, w]) => !w.min);
if (!entries.length) return;
entries.sort((a, b) => Number(b[1].el.style.zIndex || 0) - Number(a[1].el.style.zIndex || 0));
closeWindow(entries[0][0]);
}
main();