Statische Oberfläche vor Aether und InkNote: kein Backend, kein Build, nginx liefert web/ aus. Jede App bleibt ihre eigene PWA auf ihrer eigenen Domain – HoneyOS ist Startpunkt und Umschalter, kein iframe-Rahmen. - zwei Betriebsarten aus einem Datenstand: Terminal (Kacheln, Tastatur) und Desktop (Symbole, verschiebbare Fenster, Taskleiste) - honeybar.js als Drop-in für die Apps, rendert im Shadow DOM, damit Tailwind und die InkNote-Palette einander nicht in die Quere kommen - Konfiguration vollständig aus der .env: das Skript in /docker-entrypoint.d/ schreibt config.json beim Containerstart - optionaler _ME-Pfad pro App zeigt an, wer angemeldet ist; ohne CORS bleibt die Anzeige leer, ohne dass etwas kaputtgeht Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1025 lines
33 KiB
JavaScript
1025 lines
33 KiB
JavaScript
/*
|
||
* 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(`<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>`;
|
||
}
|
||
|
||
/* ── 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]) => `<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 = "<span>+ app hinzufügen</span>";
|
||
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 = `<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">−</button>
|
||
<button class="win-btn" data-act="close" title="Schließen">×</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>app hinzufügen …</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", 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) =>
|
||
`<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>
|
||
|
||
<label class="field">
|
||
<span class="label">Name</span>
|
||
<input type="text" id="setUser" value="${escapeAttr(state.cfg.user)}" maxlength="24" />
|
||
</label>
|
||
|
||
<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">Adressen</span>
|
||
${state.cfg.apps
|
||
.map(
|
||
(a) =>
|
||
`<div class="url-row"><span>${escapeHtml(a.name)}</span>
|
||
<input type="url" data-url="${a.id}" value="${escapeAttr(a.url)}" spellcheck="false" /></div>`
|
||
)
|
||
.join("")}
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button class="btn" id="btnReset">zurücksetzen</button>
|
||
<button class="btn btn-primary" id="btnSave">speichern</button>
|
||
</div>`,
|
||
(root) => {
|
||
root.querySelectorAll(".seg").forEach((segEl) => {
|
||
segEl.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("button[data-v]");
|
||
if (!btn) return;
|
||
const key = segEl.dataset.seg;
|
||
segEl.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
|
||
|
||
if (key === "mode") {
|
||
// Der Moduswechsel zeichnet alles neu — die Einstellungen sollen
|
||
// dabei offen bleiben, also anschließend erneut öffnen.
|
||
setMode(btn.dataset.v);
|
||
openSettings();
|
||
return;
|
||
}
|
||
state.settings[key] = btn.dataset.v;
|
||
saveSettings();
|
||
renderStats();
|
||
if (key === "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",
|
||
`<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) => ({ "&": "&", "<": "<", ">": ">" })[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 = `<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>`;
|
||
|
||
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();
|