Files
HoneyOS/web/js/os.js
T
marc c0abfbb491 Zwei Widgets: Zettel mit kleinem Markdown, Bild je Nutzer
Zettel: ein Textfeld, mehr nicht — InkNote gibt es schon. Gelesen wird
gesetzt (Ueberschriften, Haken, Aufzaehlungen, fett/kursiv/code, Strich),
geschrieben wird roh. Haken lassen sich ohne Bearbeiten setzen und
aendern die Quelle. Gespeichert wird 800 ms nach dem letzten Anschlag,
sofort beim Verlassen und beim Schliessen des Tabs (keepalive statt
sendBeacon: der kann nur POST und traegt keine Kopfzeilen). Grenze 4000
Zeichen, vom Server durchgesetzt.

Bild: genau eines je Nutzer, hochgeladen oder verlinkt. Die erste Datei,
die HoneyOS ablegt — in bilder/ neben honeyos.json, benannt nach einem
Fingerabdruck der Nutzerkennung. Ein neues Bild ersetzt das alte samt
Datei, die Adresse traegt einen Versionsanteil gegen den Zwischen-
speicher. SVG kommt nicht herein (Text, kann Skripte tragen), und der
Typ wird an den Bytes erkannt, nicht an der Angabe des Browsers.

Der Rahmen kann jetzt Widgets mit eigenem DOM: 'stabil' haelt den Inhalt,
'montiert' verdrahtet ihn. Ohne das haette das Anklopfen bei den Apps
alle zwei Minuten den Text unter den Fingern ausgetauscht.

Nebenbei: Der Aufrufhelfer setzte bei jedem Schreibzugriff
Content-Type: application/json — auch fuer Bildbytes.
2026-08-31 00:44:20 +02:00

1195 lines
43 KiB
JavaScript

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 { 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 "./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");
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;
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 = "";
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 = `
<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);
});
wrap.appendChild(a);
});
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 = state.cfg.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("");
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;
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>`;
openPanel(
"settings",
"einstellungen",
`
<div class="field">
<span class="label">Betriebsart</span>
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
</div>
<div class="field">
<span class="label">Röhre</span>
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
</div>
<div class="field">
<span class="label">Bildröhren-Effekte</span>
${seg("scanlines", [["on", "scanlines an"], ["off", "aus"]], s.scanlines)}
<div style="height:6px"></div>
${seg("flicker", [["on", "flackern an"], ["off", "aus"]], s.flicker)}
<div style="height:6px"></div>
${seg("glow", [["on", "glühen an"], ["off", "aus"]], s.glow)}
</div>
<div class="field">
<span class="label">HoneyBar</span>
${seg("barGlyphs", [["on", "symbole an"], ["off", "nur namen"]], s.barGlyphs)}
<p class="ae-hint" style="margin-left:0">gilt für den App-Streifen hier und in jeder App</p>
</div>
<div class="field">
<span class="label">Apps öffnen</span>
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)}
</div>
<div class="field">
<span class="label">Wetter</span>
${seg("weatherDesk", [["on", "auf dem schreibtisch"], ["off", "aus"]], s.weatherDesk)}
<div style="height:6px"></div>
${seg("weatherBar", [["on", "in der honeybar"], ["off", "aus"]], s.weatherBar)}
<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="ae-hint" id="wxCurrent" style="margin-left:0"></p>
</div>
<div class="field">
<span class="label">Programme</span>
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
</div>
<p class="login-note" style="margin-top:4px">
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
? `<div class="field field-out">
<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>
</div>`
: ""),
(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();
});
});
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
);
}
/* ── 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) => `
<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>
<span class="ae-move">
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
title="nach oben" aria-label="${escapeAttr(a.name || a.id)} nach oben">&#9650;</button>
<button class="ae-arrow" data-down="${i}" ${i === draft.length - 1 ? "disabled" : ""}
title="nach unten" aria-label="${escapeAttr(a.name || a.id)} nach unten">&#9660;</button>
</span>
</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 = draft.length
? draft.map(rowHtml).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", () => {
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 <summary>: 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",
`<div class="apped"><div id="appList"></div>
<div class="modal-actions">
<span class="saveline" id="appMsg"></span>
<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.
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",
`<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">` +
state.cfg.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("") +
`</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();
});
// Was die HoneyBar aus einer fremden App herüberreicht: Sie darf selbst
// nichts schreiben (siehe honeybar.js), also stellt sie die Frage in der
// Adresse und der Schreibtisch beantwortet sie. Danach kommt die Adresse
// weg — ein Neuladen soll die Einstellungen nicht wieder aufreißen.
if (new URLSearchParams(location.search).get("einstellungen") === "1") {
history.replaceState(null, "", location.pathname);
openSettings();
}
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();