import { $, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, 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 = `${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 = ""; state.cfg.apps.forEach((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 = ` ${glyphSvg(app.glyph)} ${escapeHtml(app.name)} ${escapeHtml(app.subtitle || "")} prüfe …`; a.addEventListener("click", (e) => { e.preventDefault(); launch(app); }); wrap.appendChild(a); }); const add = document.createElement("button"); add.className = "tile tile-add"; add.innerHTML = state.cfg.apps.length ? "+ app hinzufügen" : "Noch keine App.
Hier die erste anlegen.
"; 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 = `${glyphSvg(item.glyph)}${escapeHtml(item.name)}`; // 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 = `
${escapeHtml(title)}
${body}
`; 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 = state.cfg.apps .map( (a) => `` ) .join(""); openWindow({ id: "programme", title: "programme", w: 360, body: `
${rows}
`, 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) => `
` + opts.map(([v, l]) => ``).join("") + `
`; const zeile = (label, rechts) => `
${label}${rechts}
`; const schalter = (name, label, anLabel = "an") => zeile(label, seg(name, [["on", anLabel], ["off", "aus"]], s[name])); const sektion = (titel, inhalt) => `

${titel}

${inhalt}
`; 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") + `

Gilt auch für die HoneyBar in deinen Apps — es ist dieselbe Leiste.

` ) + sektion( "Widgets", `

Reihenfolge und An/Aus liegen bei deinem Konto, nicht am Gerät.

` ) + sektion( "Bild", bildFormularHtml() + `

Genau ein Bild je Konto; ein neues ersetzt das alte. Zu sehen ist es im Widget „bild".

` ) + sektion( "Wetter", `

` ) + sektion( "Programme", zeile("Eingetragene Apps", ``) ) + sektion( "Konto", `

Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.

` + // 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 ? `` : "") ), (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) => ``) .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) => `
  • ${escapeHtml(p.titel)}${ p.wo && p.wo.length === 1 ? `nur ${p.wo[0]}` : "" }
  • ` ) .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. */ function openApps() { let draft = state.cfg.apps.map((a) => ({ ...a })); // 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 rowHtml = (a, i) => `
    ${glyphSvg(a.glyph)} ${escapeHtml(a.name || a.id || "neue app")} ${escapeHtml(a.url || "")}

    klein, ohne Leerzeichen — muss zu data-app der HoneyBar passen

    `; const render = (root) => { const list = root.querySelector("#appList"); list.innerHTML = draft.length ? draft.map(rowHtml).join("") : `
    Noch keine App eingetragen.
    Mit „+ neue app" die erste anlegen.
    `; list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => { inp.addEventListener("input", () => { draft[Number(inp.dataset.i)][inp.dataset.f] = inp.value; }); }); list.querySelectorAll("[data-del]").forEach((b) => b.addEventListener("click", () => { draft.splice(Number(b.dataset.del), 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, i) => { d.addEventListener("toggle", () => { if (draft[i]) draft[i].__offen = d.open; }); }); // Die Pfeile sitzen im : Ohne preventDefault klappt jeder // Sortierklick die Zeile auf oder zu. const tausche = (i, j) => { if (j < 0 || j >= draft.length) return; [draft[i], draft[j]] = [draft[j], draft[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", `
    `, (root) => { render(root); root.querySelector("#btnAdd").addEventListener("click", () => { // Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen. draft.push({ id: "", name: "", url: "", glyph: "box", __offen: true }); render(root); }); root.querySelector("#btnSaveApps").addEventListener("click", async () => { const msg = root.querySelector("#appMsg"); msg.dataset.kind = ""; msg.textContent = "speichere …"; try { const clean = draft.map(({ __offen, ...rest }) => rest); state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) })).apps; const offen = new Set(draft.filter((a) => a.__offen).map((a) => a.id)); draft = state.cfg.apps.map((a) => ({ ...a, __offen: offen.has(a.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", `

    HoneyOS ${escapeHtml(state.cfg.version)} — 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.

    Zwei Betriebsarten, ein Datenstand: terminal zeigt Kacheln, desktop Symbole und Fenster. Die HoneyBar oben ist in beiden dieselbe — und dieselbe steckt per honeybar/honeybar.js auch in Aether und InkNote.

    M
    Betriebsart wechseln
    S
    Einstellungen
    ?
    diese Hilfe
    Esc
    Fenster schließen
    ` ); } /* ── 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 = `
    wechseln zu
    ` + (state.cfg.apps.length ? `
    ` + state.cfg.apps .map( (a) => `` ) .join("") + `
    ` : ``); 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 = `
    honeyos

    `; 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 = `
    starten
    ` + state.cfg.apps .map((a) => ``) .join("") + `

    `; 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 = `${escapeHtml(LOGIN_ERRORS[fehler] || "Die Anmeldung ist gescheitert (" + fehler + ").")}`; } 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();