Files
HoneyOS/web/os.js
T
marcandClaude Opus 5 1c3218939b feat: Betrieb hinter einem Anmelde-Proxy, Schlüssel für die App-Liste
Marc stellt Pangolin davor und will keinen zweiten Anmeldeschirm.

- HONEYOS_PROXY_AUTH=true: HoneyOS prüft nicht mehr selbst, der Start sagt
  deutlich, dass der Container dann nur über den Proxy erreichbar sein darf.
  Mit HONEYOS_USER_HEADER bekommt jeder Name seine eigenen Einstellungen.
- HONEYOS_APPS_KEY schützt /api/apps. Nötig, weil HoneyOS auf einer anderen
  Site liegt als die Apps (duckdns.org steht auf der Public-Suffix-Liste) –
  der Browser schickt die Sitzung dort nicht mit, und der Pfad muss im Proxy
  von der Anmeldung befreit sein.

Beim Durchspielen mit zwei echten Hostnamen fiel ein Loch auf: Ohne eigene
Anmeldung gilt jede Anfrage als angemeldet, also gab der Rückfall auf diese
Prüfung die Liste an jeden heraus – auch ohne oder mit falschem Schlüssel.
Der Rückfall greift jetzt nur noch, wo es wirklich eine Sitzung geben kann.

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

1247 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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.
*/
const LS_SETTINGS = "honeyos.settings";
const SS_BOOTED = "honeyos.booted";
const VERSION = "0.2";
const DEFAULT_SETTINGS = {
mode: "terminal", // terminal | desktop
phosphor: "amber", // amber | green | ice
scanlines: "on",
flicker: "on",
glow: "on",
openIn: "tab", // tab | same
};
const state = {
cfg: { user: "", hostname: "honeyos", version: VERSION, apps: [] },
me: null, // {sub, name, picture} sobald angemeldet
oidc: false,
settings: { ...DEFAULT_SETTINGS },
probes: {}, // appId -> "up" | "down" | "unknown"
auth: {}, // appId -> {status: "in"|"out"|"blind", name}
wins: {}, // id -> {el, task, min}
zTop: 10,
};
const $ = (id) => document.getElementById(id);
const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
/* ── Pixel-Glyphen ─────────────────────────────────────────────────────────
* 12×12-Raster als Zeichenketten. Ein `#` ist ein Pixel. So bleiben die Icons
* im Retro-Raster statt aus einer Icon-Bibliothek zu kommen.
*/
const GLYPHS = {
cloud: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
"#..........#",
"#..........#",
".##########.",
"............",
"............",
"............",
],
note: [
"..########..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.##...#..",
"..#......#..",
"..########..",
"............",
],
box: [
"............",
".##########.",
".#........#.",
".#........#.",
".#...##...#.",
".#..####..#.",
".#..####..#.",
".#...##...#.",
".#........#.",
".#........#.",
".##########.",
"............",
],
chip: [
"............",
"..#.#.#.#...",
".##########.",
".#........#.",
"##.######.##",
".#.#....#.#.",
"##.#....#.##",
".#.######.#.",
".#........#.",
".##########.",
"..#.#.#.#...",
"............",
],
gear: [
"....####....",
"..#.####.#..",
"..########..",
".####..####.",
"###......###",
"##........##",
"##........##",
"###......###",
".####..####.",
"..########..",
"..#.####.#..",
"....####....",
],
globe: [
"............",
"...######...",
"..#..#...#..",
".#...#....#.",
"############",
"#....#.....#",
"#....#.....#",
"############",
".#...#....#.",
"..#..#...#..",
"...######...",
"............",
],
disk: [
"............",
"############",
"#..######..#",
"#..#....#..#",
"#..#....#..#",
"#..######..#",
"#..........#",
"#.########.#",
"#.#......#.#",
"#.#......#.#",
"############",
"............",
],
folder: [
"............",
".####.......",
"#....#......",
"#.....######",
"#..........#",
"#..........#",
"#..........#",
"#..........#",
"#..........#",
"############",
"............",
"............",
],
};
function glyphSvg(name) {
const grid = GLYPHS[name] || GLYPHS.box;
const rects = [];
grid.forEach((row, y) => {
// Waagerechte Läufe zusammenfassen: weniger Rechtecke, gleiches Bild.
let run = 0;
for (let x = 0; x <= row.length; x++) {
if (row[x] === "#") {
run++;
} else if (run) {
rects.push(`<rect x="${x - run}" y="${y}" width="${run}" height="1"/>`);
run = 0;
}
}
});
return `<svg viewBox="0 0 12 12" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${rects.join("")}</svg>`;
}
/* ── Server ────────────────────────────────────────────────────────────────
* Ein winziger Aufrufhelfer. Schreibende Aufrufe brauchen die CSRF-Kopfzeile:
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
* die Seite wirklich geöffnet hat.
*/
/** Der Schlüssel für die App-Liste steht als Meta-Angabe in der Seite — der
* Server legt ihn dort hinein, wenn einer eingerichtet ist. */
const APPS_KEY = document.querySelector('meta[name="honeyos-key"]')?.content || "";
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
if (opts.method && opts.method !== "GET") {
headers["Content-Type"] = "application/json";
const tok = cookie("honeyos_csrf");
if (tok) headers["X-CSRF-Token"] = tok;
}
const res = await fetch(path, { credentials: "same-origin", cache: "no-store", ...opts, headers });
if (res.status === 204) return null;
const body = await res.json().catch(() => null);
if (!res.ok) throw new Error((body && body.error) || `${res.status} ${res.statusText}`);
return body;
}
function cookie(name) {
return document.cookie
.split(";")
.map((c) => c.trim().split("="))
.find(([k]) => k === name)?.[1] || "";
}
/* ── 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 };
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");
}
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
* aufblitzt und dann umspringt. */
function loadLocalSettings() {
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
}
function readJson(key) {
try {
return JSON.parse(localStorage.getItem(key) || "null");
} catch {
return null;
}
}
let syncTimer = null;
function saveSettings() {
try {
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
} catch {
/* Privatmodus: dann gilt es nur für diese Sitzung — der Server merkt es trotzdem */
}
applySettings();
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
// soll nicht dreimal schreiben lassen.
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
console.warn("Einstellungen nicht gespeichert:", e.message)
);
}, 600);
}
function applySettings() {
const r = document.documentElement;
r.dataset.phosphor = state.settings.phosphor;
r.dataset.scanlines = state.settings.scanlines;
r.dataset.flicker = state.settings.flicker;
r.dataset.glow = state.settings.glow;
r.dataset.mode = state.settings.mode;
}
/* ── 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 greeting() {
const h = new Date().getHours();
if (h < 5 || h >= 23) return "Noch wach?";
if (h < 11) return "Guten Morgen.";
if (h < 18) return "Willkommen zurück.";
return "Guten Abend.";
}
function renderShell() {
$("desktop").hidden = false;
$("mbUser").textContent = state.cfg.user;
if (state.me && state.me.picture) {
$("mbAvatar").src = state.me.picture;
$("mbAvatar").hidden = false;
}
$("btnLogout").hidden = !state.oidc;
renderMenubarApps();
renderMode();
tickClock();
}
function renderMenubarApps() {
const nav = $("mbApps");
nav.innerHTML = "";
state.cfg.apps.forEach((app) => {
const b = document.createElement("button");
b.className = "mb-app";
b.textContent = 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();
}
renderStats();
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. */
function renderStats() {
const up = Object.values(state.probes).filter((s) => s === "up").length;
const rows = [
["apps", `${state.cfg.apps.length}`],
["erreichbar", `${up}/${state.cfg.apps.length}`],
["modus", state.settings.mode],
["röhre", state.settings.phosphor],
["version", `v${state.cfg.version}`],
];
const html = rows.map(([k, v]) => `<dt>${k}</dt><dd>${escapeHtml(v)}</dd>`).join("");
document.querySelectorAll(".statlist").forEach((dl) => (dl.innerHTML = html));
document.querySelectorAll(".greet").forEach(
(p) => (p.textContent = `${greeting()} ${state.cfg.user}@${state.cfg.hostname}`)
);
}
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();
checkAuth();
}
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);
}
}
/* ── Anmeldestand ──────────────────────────────────────────────────────────
* Wenn eine App einen "me"-Endpunkt in der Konfiguration hat, fragen wir ihn
* mit den Keksen des Browsers. Das funktioniert nur, weil alle Adressen unter
* derselben Domain liegen — sonst schickt der Browser die Sitzung nicht mit.
*
* Antwortet die App nicht oder verbietet CORS, ist der Stand "blind": dann
* zeigt HoneyOS eben nur die Lampe wie bisher. Kein Fehler, keine Meldung —
* die Anzeige ist eine Zugabe, nichts, worauf sich jemand verlassen muss.
*/
async function checkAuth() {
await Promise.all(
state.cfg.apps
.filter((a) => a.me)
.map(async (app) => {
state.auth[app.id] = await askMe(app);
})
);
paintProbes();
applyAuthName();
}
async function askMe(app) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 5000);
try {
const headers = {};
// Apps mit Bearer-Token statt Cookie: der Schlüssel steht in der
// Konfiguration, der Wert im localStorage — und der ist pro Domain.
// Von hier aus lesbar ist er nur, wenn HoneyOS dieselbe Domain ist.
if (app.tokenKey) {
try {
const tok = localStorage.getItem(app.tokenKey);
if (tok) headers.Authorization = `Bearer ${tok}`;
} catch {
/* kein Speicherzugriff — dann ohne */
}
}
const res = await fetch(resolveUrl(app, app.me), {
credentials: "include",
cache: "no-store",
headers,
signal: ctl.signal,
});
if (res.status === 401 || res.status === 403) return { status: "out" };
if (!res.ok) return { status: "blind" };
return { status: "in", name: extractName(await res.json()) };
} catch {
return { status: "blind" };
} finally {
clearTimeout(t);
}
}
/** Relative Pfade beziehen sich auf die App-Adresse, absolute gelten wie sie sind. */
function resolveUrl(app, path) {
if (!path) return app.url;
if (/^https?:\/\//i.test(path)) return path;
return app.url.replace(/\/+$/, "") + (path.startsWith("/") ? path : "/" + path);
}
/** Jede App antwortet anders — hier wird der Anzeigename herausgefischt. */
function extractName(data) {
const u = (data && (data.user || data.profile)) || data || {};
return u.display_name || u.displayName || u.name || u.username || u.preferred_username || "";
}
/** Nennt eine App einen Namen, gilt der — er kommt vom Anbieter, die
* Einstellung ist nur der Platzhalter, solange niemand antwortet. */
function applyAuthName() {
const named = Object.values(state.auth).find((a) => a.status === "in" && a.name);
if (named) {
$("mbUser").textContent = named.name;
renderStats();
}
}
const STATE_TEXT = { up: "online", down: "keine antwort", unknown: "prüfe …" };
const AUTH_TEXT = { in: "angemeldet", out: "nicht angemeldet" };
function stateLine(appId) {
const s = state.probes[appId] || "unknown";
const a = state.auth[appId];
const extra = a && AUTH_TEXT[a.status] ? ` · ${a.status === "in" && a.name ? a.name : AUTH_TEXT[a.status]}` : "";
return { s, text: STATE_TEXT[s] + (s === "up" ? extra : "") };
}
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 = `
${app.key ? `<span class="tile-key">[${app.key}]</span>` : ""}
<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,
body: `<p class="greet"></p><dl class="statlist"></dl>`,
onMount: renderStats,
});
}
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">Apps öffnen</span>
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)}
</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>`,
(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 === "openIn" && state.settings.mode === "terminal") renderTiles();
});
});
root.querySelector("#btnApps").addEventListener("click", openApps);
},
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 }));
const GLYPHS_LIST = ["cloud", "note", "box", "chip", "folder", "gear", "globe", "disk"];
const rowHtml = (a, i) => `
<details class="approw" ${a.__neu ? "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>
</summary>
<div class="ae-body">
<label class="ae-field"><span>Kennung</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 Leiste 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>
<label class="ae-field"><span>Taste</span>
<input data-i="${i}" data-f="key" value="${escapeAttr(a.key || "")}" maxlength="1" placeholder="1" /></label>
<label class="ae-field"><span>Wer-bin-ich</span>
<input data-i="${i}" data-f="me" value="${escapeAttr(a.me || "")}" placeholder="/api/v1/auth/me" spellcheck="false" /></label>
<p class="ae-hint">optional — zeigt an, ob du dort angemeldet bist</p>
<label class="ae-field"><span>Token-Schlüssel</span>
<input data-i="${i}" data-f="tokenKey" value="${escapeAttr(a.tokenKey || "")}" placeholder="leer lassen" spellcheck="false" /></label>
<p class="ae-hint">nur für Apps mit Bearer-Token im localStorage statt Cookie</p>
<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);
})
);
};
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", () => {
draft.push({ id: "", name: "", url: "", glyph: "box", key: String(draft.length + 1), __neu: 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(({ __neu, ...rest }) => rest);
state.cfg.apps = await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) });
draft = state.cfg.apps.map((a) => ({ ...a }));
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 Leiste darin.</p>
<p>Zwei Betriebsarten, ein Datenstand: <code>terminal</code> zeigt Kacheln,
<code>desktop</code> Symbole und Fenster. Die Leiste oben ist in beiden dieselbe —
und dieselbe steckt per <code>honeybar/honeybar.js</code> auch in Aether und InkNote.</p>
<dl class="keys">
${state.cfg.apps.map((a) => `<dt>${a.key || "·"}</dt><dd>${escapeHtml(a.name)} starten</dd>`).join("")}
<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 ───────────────────────────────────────────────────────── */
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, "&quot;");
}
/* ── 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();
menu.innerHTML = `<div class="sm-head">starten</div>` +
state.cfg.apps.map((a) => `<button data-app="${a.id}">${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>
<button data-logout="1">⏻ Abmelden</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.querySelector("[data-logout]").addEventListener("click", logout);
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-Kennung 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 ${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");
} 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();
probeApps();
setInterval(tickClock, 15000);
setInterval(probeApps, 120000);
$("btnSettings").addEventListener("click", openSettings);
$("btnAbout").addEventListener("click", openAbout);
$("btnLogout").addEventListener("click", logout);
$("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();
return state.settings.mode === "desktop" ? closeTopWindow() : closeModal();
}
if (typing) return;
const app = state.cfg.apps.find((a) => a.key === e.key);
if (app) return launch(app);
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();