/*
* 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 LS_CFG = "honeyos.config";
const SS_BOOTED = "honeyos.booted";
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: "marc", hostname: "honeyos", version: "0.1", apps: [] },
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: [
"....####....",
"..#.####.#..",
"..########..",
".####..####.",
"###......###",
"##........##",
"##........##",
"###......###",
".####..####.",
"..########..",
"..#.####.#..",
"....####....",
],
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(` `);
run = 0;
}
}
});
return `${rects.join("")} `;
}
/* ── Laden & Speichern ─────────────────────────────────────────────────── */
async function loadConfig() {
let base = null;
try {
const res = await fetch("./config.json", { cache: "no-store" });
if (res.ok) base = await res.json();
} catch {
/* offline oder file:// — dann zählt allein der lokale Stand */
}
if (base) state.cfg = { ...state.cfg, ...base };
const saved = readJson(LS_CFG);
if (saved) {
// Gespeichert wird nur, was der Nutzer wirklich geändert hat: Name und URLs.
if (saved.user) state.cfg.user = saved.user;
if (saved.urls) {
state.cfg.apps = state.cfg.apps.map((a) => (saved.urls[a.id] ? { ...a, url: saved.urls[a.id] } : a));
}
}
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
}
function readJson(key) {
try {
return JSON.parse(localStorage.getItem(key) || "null");
} catch {
return null;
}
}
function saveSettings() {
try {
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
} catch {
/* Privatmodus: Einstellungen gelten dann nur für diese Sitzung */
}
applySettings();
}
function saveConfig() {
const urls = {};
state.cfg.apps.forEach((a) => (urls[a.id] = a.url));
try {
localStorage.setItem(LS_CFG, JSON.stringify({ user: state.cfg.user, urls }));
} catch {
/* siehe oben */
}
}
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: "lade benutzerprofil".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;
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]) => `
${k} ${escapeHtml(v)} `).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 = ` ${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 ? `[${app.key}] ` : ""}
${glyphSvg(app.glyph)}
${escapeHtml(app.name)}
${escapeHtml(app.subtitle || "")}
prüfe … `;
a.addEventListener("click", (e) => {
e.preventDefault();
launch(app);
});
wrap.appendChild(a);
});
const add = document.createElement("button");
add.className = "tile tile-add";
add.innerHTML = "+ app hinzufügen ";
add.addEventListener("click", openSettings);
wrap.appendChild(add);
}
/* ══ Ansicht 2: Desktop-Modus ════════════════════════════════════════════ */
function renderDeskIcons() {
const wrap = $("deskIcons");
wrap.innerHTML = "";
const items = [
...state.cfg.apps.map((app) => ({
id: app.id,
name: app.name,
glyph: app.glyph,
app,
run: () => launch(app),
})),
{ id: "programme", name: "Programme", glyph: "folder", run: openProgramsWindow },
{ id: "system", name: "System", glyph: "chip", run: openSystemWindow },
{ id: "settings", name: "Einstellungen", glyph: "gear", run: openSettings },
];
items.forEach((item) => {
const b = document.createElement("button");
b.className = "deskicon";
b.dataset.name = item.name;
if (item.app) b.dataset.app = item.app.id;
b.innerHTML = `${glyphSvg(item.glyph)} ${escapeHtml(item.name)} `;
// Am Zeigegerät wie früher: einmal auswählen, zweimal öffnen.
// Am Finger wäre das eine Zumutung — dort öffnet die erste Berührung.
b.addEventListener("click", () => {
selectIcon(b);
if (isTouch()) item.run();
});
b.addEventListener("dblclick", item.run);
b.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
item.run();
}
});
wrap.appendChild(b);
});
wrap.addEventListener("pointerdown", (e) => {
if (e.target === wrap) selectIcon(null);
});
}
function selectIcon(el) {
document.querySelectorAll(".deskicon[data-sel]").forEach((i) => delete i.dataset.sel);
if (el) el.dataset.sel = "1";
}
/* ── Fenster ───────────────────────────────────────────────────────────── */
/**
* Legt ein Fenster an oder holt ein vorhandenes nach vorn.
* `body` ist fertiges HTML, `onMount` bekommt den Rumpf zum Verdrahten.
*/
function openWindow({ id, title, body, w = 340, x, y, onMount }) {
const existing = state.wins[id];
if (existing) {
existing.min = false;
delete existing.el.dataset.min;
focusWindow(id);
updateTaskbar();
return existing.el;
}
const layer = $("windows");
const bounds = layer.getBoundingClientRect();
const width = Math.min(w, Math.max(200, bounds.width - 24));
const count = Object.keys(state.wins).length;
const el = document.createElement("section");
el.className = "dwin";
el.style.width = width + "px";
// Links liegt die Symbolspalte — Fenster starten rechts davon, sonst deckt
// das erste Fenster gleich die App-Symbole zu.
const startX = bounds.width > 420 ? 124 : 8;
el.style.left = (x ?? Math.min(startX + count * 26, Math.max(8, bounds.width - width - 8))) + "px";
el.style.top = (y ?? 18 + count * 24) + "px";
el.innerHTML = `
${escapeHtml(title)}
−
×
${body}
`;
layer.appendChild(el);
state.wins[id] = { el, min: false, title };
el.querySelector('[data-act="close"]').addEventListener("click", () => closeWindow(id));
el.querySelector('[data-act="min"]').addEventListener("click", () => minimizeWindow(id));
el.addEventListener("pointerdown", () => focusWindow(id));
makeDraggable(el, el.querySelector(".win-bar"), layer);
if (onMount) onMount(el.querySelector(".win-body"));
focusWindow(id);
updateTaskbar();
return el;
}
function focusWindow(id) {
Object.entries(state.wins).forEach(([wid, w]) => (w.el.dataset.focus = wid === id ? "1" : "0"));
const w = state.wins[id];
if (w) w.el.style.zIndex = String(++state.zTop);
updateTaskbar();
}
function closeWindow(id) {
const w = state.wins[id];
if (!w) return;
w.el.remove();
delete state.wins[id];
updateTaskbar();
}
function closeAllWindows() {
Object.keys(state.wins).forEach(closeWindow);
}
function minimizeWindow(id) {
const w = state.wins[id];
if (!w) return;
w.min = true;
w.el.dataset.min = "1";
updateTaskbar();
}
/** Ziehen am Titelbalken. Pointer Events, damit Maus und Finger denselben
* Weg nehmen; die Position wird im sichtbaren Bereich gehalten. */
function makeDraggable(win, bar, layer) {
bar.addEventListener("pointerdown", (e) => {
if (e.target.closest(".win-btn")) return;
const wr = win.getBoundingClientRect();
const lr = layer.getBoundingClientRect();
const offX = e.clientX - wr.left;
const offY = e.clientY - wr.top;
win.dataset.drag = "1";
bar.setPointerCapture(e.pointerId);
const move = (ev) => {
const x = Math.max(0, Math.min(ev.clientX - lr.left - offX, lr.width - wr.width));
const y = Math.max(0, Math.min(ev.clientY - lr.top - offY, lr.height - 26));
win.style.left = x + "px";
win.style.top = y + "px";
};
const up = () => {
delete win.dataset.drag;
bar.removeEventListener("pointermove", move);
bar.removeEventListener("pointerup", up);
bar.removeEventListener("pointercancel", up);
};
bar.addEventListener("pointermove", move);
bar.addEventListener("pointerup", up);
bar.addEventListener("pointercancel", up);
});
}
function updateTaskbar() {
const list = $("taskList");
if (!list) return;
list.innerHTML = "";
Object.entries(state.wins).forEach(([id, w]) => {
const b = document.createElement("button");
b.className = "task-btn";
b.textContent = w.title;
b.dataset.active = !w.min && w.el.dataset.focus === "1" ? "1" : "0";
b.addEventListener("click", () => {
if (w.min) {
w.min = false;
delete w.el.dataset.min;
focusWindow(id);
} else if (w.el.dataset.focus === "1") {
minimizeWindow(id);
} else {
focusWindow(id);
}
});
list.appendChild(b);
});
}
/* ── Fensterinhalte ────────────────────────────────────────────────────── */
function openSystemWindow() {
openWindow({
id: "system",
title: "system",
w: 320,
body: `
`,
onMount: renderStats,
});
}
function openProgramsWindow() {
const rows = state.cfg.apps
.map(
(a) => `
${glyphSvg(a.glyph)}
${escapeHtml(a.name)}
${escapeHtml(a.subtitle || "")}
`
)
.join("");
openWindow({
id: "programme",
title: "programme",
w: 360,
body: `${rows}
${glyphSvg("gear")} app hinzufügen …
`,
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", openSettings);
},
});
}
/* ── 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) =>
`` +
opts.map(([v, l]) => `${l} `).join("") +
`
`;
openPanel(
"settings",
"einstellungen",
`
Betriebsart
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
Name
Röhre
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
Bildröhren-Effekte
${seg("scanlines", [["on", "scanlines an"], ["off", "aus"]], s.scanlines)}
${seg("flicker", [["on", "flackern an"], ["off", "aus"]], s.flicker)}
${seg("glow", [["on", "glühen an"], ["off", "aus"]], s.glow)}
Apps öffnen
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)}
Adressen
${state.cfg.apps
.map(
(a) =>
`
${escapeHtml(a.name)}
`
)
.join("")}
zurücksetzen
speichern
`,
(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("#btnSave").addEventListener("click", () => {
const user = root.querySelector("#setUser").value.trim();
if (user) state.cfg.user = user;
root.querySelectorAll("input[data-url]").forEach((inp) => {
const app = state.cfg.apps.find((a) => a.id === inp.dataset.url);
const val = inp.value.trim();
if (app && val) app.url = val;
});
saveConfig();
if (state.settings.mode === "desktop") closeWindow("settings");
else closeModal();
$("mbUser").textContent = state.cfg.user;
renderMenubarApps();
renderMode();
probeApps();
});
root.querySelector("#btnReset").addEventListener("click", () => {
localStorage.removeItem(LS_SETTINGS);
localStorage.removeItem(LS_CFG);
location.reload();
});
},
420
);
}
function openAbout() {
openPanel(
"about",
"über honeyos",
`
HoneyOS ${escapeHtml(state.cfg.version)} — eine Oberfläche für mehrere eigene Apps.
Jede App bleibt ihre eigene Seite auf ihrer eigenen Domain; HoneyOS ist der Schreibtisch
davor und die Leiste darin.
Zwei Betriebsarten, ein Datenstand: terminal zeigt Kacheln,
desktop Symbole und Fenster. Die Leiste oben ist in beiden dieselbe —
und dieselbe steckt per honeybar/honeybar.js auch in Aether und InkNote.
${state.cfg.apps.map((a) => `${a.key || "·"} ${escapeHtml(a.name)} starten `).join("")}
M Betriebsart wechseln
S Einstellungen
? diese Hilfe
Esc Fenster schließen
`
);
}
/* ── Hilfsmittel ───────────────────────────────────────────────────────── */
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, """);
}
/* ── 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 = `starten
` +
state.cfg.apps.map((a) => `${escapeHtml(a.name)} `).join("") +
`Programme
System
Einstellungen
Über HoneyOS
↩ Terminal-Modus `;
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 ─────────────────────────────────────────────────────────────── */
async function main() {
await loadConfig();
applySettings();
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);
$("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)$/.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();