Files
HoneyOS/web/js/os.js
T

1251 lines
45 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 {
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");
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;
/* 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.
*/
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();