Anklickbar war es ein Versprechen, das es nicht hielt: Der Klick fuehrte nur dorthin, wo ohnehin alles eingestellt wird. Jetzt eine Anzeige mit Hinweistext, in beiden Leisten. Die Anordnung war seit dem Wetterfenster kaputt. Das Raster verteilt seine Bereiche der Reihe nach — das dritte landete neben „system" und schob die Kacheln in die schmale linke Spalte. Links stehen jetzt die Anzeigen untereinander, rechts die Apps ueber die volle Hoehe. Keine neue Faehigkeit, also bleibt die Version bei 0.9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1485 lines
49 KiB
JavaScript
1485 lines
49 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 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
|
||
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die Leiste sind
|
||
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
|
||
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
|
||
weatherBar: "off",
|
||
weatherDesk: "off",
|
||
weatherCity: "",
|
||
weatherLat: "",
|
||
weatherLon: "",
|
||
};
|
||
|
||
const state = {
|
||
cfg: { user: "", hostname: "honeyos", version: "…", apps: [] },
|
||
me: null, // {sub, name, picture} sobald angemeldet
|
||
weather: null, // zuletzt geholtes Wetter, oder null
|
||
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 = {
|
||
// Wettersymbole. Dieselben Namen wie in weather.go (wmo) und in der Leiste —
|
||
// solange 1.3 (geteilte Symbolquelle) nicht gebaut ist, stehen sie zweimal da.
|
||
sonne: [
|
||
"............",
|
||
".....##.....",
|
||
"..#..##..#..",
|
||
"...#....#...",
|
||
"....####....",
|
||
"..##....##..",
|
||
"..##....##..",
|
||
"....####....",
|
||
"...#....#...",
|
||
"..#..##..#..",
|
||
".....##.....",
|
||
"............",
|
||
],
|
||
heiter: [
|
||
"............",
|
||
"...##.......",
|
||
"..####..#...",
|
||
"...##..###..",
|
||
"......#####.",
|
||
"...####...#.",
|
||
"..#....##.#.",
|
||
".##........#",
|
||
"#..........#",
|
||
".##########.",
|
||
"............",
|
||
"............",
|
||
],
|
||
wolke: [
|
||
"............",
|
||
"....####....",
|
||
"...#....#...",
|
||
"..#......##.",
|
||
".##........#",
|
||
"##.........#",
|
||
"#..........#",
|
||
"#..........#",
|
||
".##########.",
|
||
"............",
|
||
"............",
|
||
"............",
|
||
],
|
||
nebel: [
|
||
"............",
|
||
"....####....",
|
||
"..##....##..",
|
||
".##.......#.",
|
||
"#..........#",
|
||
".##########.",
|
||
"............",
|
||
".#########..",
|
||
"............",
|
||
"..#########.",
|
||
"............",
|
||
".#########..",
|
||
],
|
||
regen: [
|
||
"............",
|
||
"....####....",
|
||
"...#....#...",
|
||
"..#......##.",
|
||
".##........#",
|
||
"##.........#",
|
||
".##########.",
|
||
"............",
|
||
"..#..#..#...",
|
||
".#..#..#....",
|
||
"..#..#..#...",
|
||
".#..#..#....",
|
||
],
|
||
schnee: [
|
||
"............",
|
||
"....####....",
|
||
"...#....#...",
|
||
"..#......##.",
|
||
".##........#",
|
||
"##.........#",
|
||
".##########.",
|
||
"............",
|
||
"..#...#...#.",
|
||
".#.#.#.#.#..",
|
||
"..#...#...#.",
|
||
"............",
|
||
],
|
||
gewitter: [
|
||
"............",
|
||
"....####....",
|
||
"...#....#...",
|
||
"..#......##.",
|
||
".##........#",
|
||
"##.........#",
|
||
".##########.",
|
||
"......##....",
|
||
".....##.....",
|
||
"....####....",
|
||
"......##....",
|
||
".....##.....",
|
||
],
|
||
|
||
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 };
|
||
if (me.version) state.cfg.version = me.version;
|
||
state.oidc = !!me.oidc;
|
||
state.cfg.user = me.name || "";
|
||
|
||
// Der Server hat das letzte Wort; localStorage war nur der Vorschuss.
|
||
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}), ...(me.settings || {}) };
|
||
state.cfg.apps = (await api("/api/apps")).apps;
|
||
}
|
||
|
||
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
|
||
* aufblitzt und dann umspringt. */
|
||
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();
|
||
// Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des
|
||
// Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt,
|
||
// in dem der Server seinen Zwischenspeicher erneuert.
|
||
loadWeather();
|
||
setInterval(loadWeather, 10 * 60 * 1000);
|
||
}
|
||
|
||
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();
|
||
renderWeather();
|
||
paintProbes();
|
||
}
|
||
|
||
/* ── Wetter ────────────────────────────────────────────────────────────────
|
||
*
|
||
* Der Server holt es und legt es zehn Minuten beiseite; hier wird nur gezeigt,
|
||
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
|
||
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
|
||
*/
|
||
async function loadWeather() {
|
||
if (!state.settings.weatherCity) {
|
||
state.weather = null;
|
||
renderWeather();
|
||
return;
|
||
}
|
||
try {
|
||
state.weather = await api("/api/weather");
|
||
} catch {
|
||
state.weather = null;
|
||
}
|
||
renderWeather();
|
||
}
|
||
|
||
function weatherHtml(w) {
|
||
return `
|
||
<div class="wx">
|
||
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
|
||
<div class="wx-text">
|
||
<b class="wx-temp">${Math.round(w.grad)}°</b>
|
||
<span class="wx-desc">${w.text}</span>
|
||
<span class="wx-city">${w.ort}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderWeather() {
|
||
const w = state.weather;
|
||
const tile = $("wxTile");
|
||
const desk = $("wxDesk");
|
||
const bar = $("mbWx");
|
||
const desktopMode = state.settings.mode === "desktop";
|
||
|
||
// Das Widget: Fenster im Terminal-Modus, Schild auf dem Schreibtisch.
|
||
const alsWidget = state.settings.weatherDesk === "on" && w;
|
||
tile.hidden = !alsWidget || desktopMode;
|
||
desk.hidden = !alsWidget || !desktopMode;
|
||
if (alsWidget) {
|
||
const html = weatherHtml(w);
|
||
tile.querySelector(".wx-body").innerHTML = html;
|
||
desk.innerHTML = html;
|
||
}
|
||
|
||
// Und in der Leiste. Die Menüleiste hier ist zwar anderer Code als
|
||
// honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe Leiste —
|
||
// ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
|
||
const inLeiste = state.settings.weatherBar === "on" && w;
|
||
bar.hidden = !inLeiste;
|
||
if (inLeiste) {
|
||
bar.innerHTML = `${glyphSvg(w.glyph)}<span class="mb-wx-temp">${Math.round(w.grad)}°</span>`;
|
||
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
|
||
}
|
||
}
|
||
|
||
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">−</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>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">Wetter</span>
|
||
${seg("weatherDesk", [["on", "auf dem schreibtisch"], ["off", "aus"]], s.weatherDesk)}
|
||
<div style="height:6px"></div>
|
||
${seg("weatherBar", [["on", "in der leiste"], ["off", "aus"]], s.weatherBar)}
|
||
<div class="wx-city-row">
|
||
<input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" />
|
||
<button class="btn" id="btnWxSearch">suchen</button>
|
||
</div>
|
||
<div id="wxResults" class="wx-results"></div>
|
||
<p class="ae-hint" id="wxCurrent" style="margin-left:0"></p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<span class="label">Programme</span>
|
||
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
|
||
</div>
|
||
|
||
<p class="login-note" style="margin-top:4px">
|
||
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
||
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
|
||
</p>`,
|
||
(root) => {
|
||
root.querySelectorAll(".seg").forEach((segEl) => {
|
||
segEl.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("button[data-v]");
|
||
if (!btn) return;
|
||
const key = segEl.dataset.seg;
|
||
segEl.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
|
||
|
||
if (key === "mode") {
|
||
// Der Moduswechsel zeichnet alles neu — die Einstellungen sollen
|
||
// dabei offen bleiben, also anschließend erneut öffnen.
|
||
setMode(btn.dataset.v);
|
||
openSettings();
|
||
return;
|
||
}
|
||
state.settings[key] = btn.dataset.v;
|
||
saveSettings();
|
||
renderStats();
|
||
if (key.startsWith("weather")) loadWeather();
|
||
if (key === "openIn" && state.settings.mode === "terminal") renderTiles();
|
||
});
|
||
});
|
||
|
||
const zeigeOrt = () => {
|
||
root.querySelector("#wxCurrent").textContent = state.settings.weatherCity
|
||
? `eingestellt: ${state.settings.weatherCity}`
|
||
: "kein Ort eingestellt — ohne Ort bleibt das Wetter aus.";
|
||
};
|
||
zeigeOrt();
|
||
|
||
const suchen = async () => {
|
||
const q = root.querySelector("#wxQuery").value.trim();
|
||
const out = root.querySelector("#wxResults");
|
||
if (!q) return;
|
||
out.textContent = "suche …";
|
||
let orte;
|
||
try {
|
||
orte = await api("/api/weather/search?q=" + encodeURIComponent(q));
|
||
} catch {
|
||
out.textContent = "Ortssuche nicht erreichbar.";
|
||
return;
|
||
}
|
||
if (!orte || !orte.length) {
|
||
out.textContent = "nichts gefunden.";
|
||
return;
|
||
}
|
||
out.innerHTML = orte
|
||
.map((o, i) => `<button type="button" class="wx-hit" data-i="${i}">${o.name}<span>${o.land}</span></button>`)
|
||
.join("");
|
||
out.querySelectorAll(".wx-hit").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const o = orte[Number(b.dataset.i)];
|
||
// Koordinaten mitspeichern: Sonst müsste jede Wetterabfrage den Ort
|
||
// erneut suchen — zwei Anfragen nach draußen statt einer.
|
||
state.settings.weatherCity = o.name;
|
||
state.settings.weatherLat = String(o.lat);
|
||
state.settings.weatherLon = String(o.lon);
|
||
saveSettings();
|
||
out.innerHTML = "";
|
||
root.querySelector("#wxQuery").value = "";
|
||
zeigeOrt();
|
||
loadWeather();
|
||
})
|
||
);
|
||
};
|
||
root.querySelector("#btnWxSearch").addEventListener("click", suchen);
|
||
root.querySelector("#wxQuery").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); suchen(); }
|
||
});
|
||
|
||
root.querySelector("#btnApps").addEventListener("click", openApps);
|
||
},
|
||
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 & 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) })).apps;
|
||
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) => ({ "&": "&", "<": "<", ">": ">" })[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>
|
||
<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 ${state.cfg.version} — ${state.cfg.hostname}`,
|
||
"",
|
||
"prüfe sitzung".padEnd(28, ".") + " keine",
|
||
"anmeldung erforderlich",
|
||
];
|
||
log.textContent = lines.join("\n") + "\n";
|
||
|
||
const note = $("loginNote");
|
||
if (fehler) {
|
||
note.innerHTML = `<b>${escapeHtml(LOGIN_ERRORS[fehler] || "Die Anmeldung ist gescheitert (" + fehler + ").")}</b>`;
|
||
} else {
|
||
note.textContent = "Die Anmeldung läuft über deinen Anbieter. HoneyOS speichert kein Passwort.";
|
||
}
|
||
|
||
const btn = $("btnLogin");
|
||
$("loginLabel").textContent = pub.buttonLabel || "Anmelden";
|
||
btn.addEventListener("click", () => {
|
||
btn.disabled = true;
|
||
$("loginLabel").textContent = "weiterleiten …";
|
||
location.href = "/api/auth/login";
|
||
});
|
||
btn.focus();
|
||
}
|
||
|
||
async function logout() {
|
||
try {
|
||
await api("/api/auth/logout", { method: "POST" });
|
||
} catch {
|
||
/* egal — die Sitzung ist so oder so nichts mehr wert */
|
||
}
|
||
try {
|
||
sessionStorage.removeItem(SS_BOOTED);
|
||
} catch {
|
||
/* dann bootet es beim nächsten Mal eben neu */
|
||
}
|
||
location.href = "/";
|
||
}
|
||
|
||
/* ── Start ─────────────────────────────────────────────────────────────── */
|
||
|
||
async function main() {
|
||
// Einstellungen aus dem letzten Besuch gelten sofort, damit die Röhre beim
|
||
// Laden nicht in der falschen Farbe aufflackert.
|
||
loadLocalSettings();
|
||
applySettings();
|
||
|
||
let pub = { oidc: false, buttonLabel: "Anmelden" };
|
||
try {
|
||
pub = await api("/api/public");
|
||
if (pub.version) state.cfg.version = pub.version;
|
||
} catch {
|
||
/* Server nicht erreichbar — der Anmeldeschirm sagt es gleich selbst */
|
||
}
|
||
|
||
try {
|
||
await loadFromServer();
|
||
} catch {
|
||
return showLogin(pub); // nicht angemeldet: hier ist Schluss
|
||
}
|
||
|
||
applySettings(); // jetzt gilt, was der Server gespeichert hat
|
||
|
||
let skipBoot = false;
|
||
try {
|
||
skipBoot = sessionStorage.getItem(SS_BOOTED) === "1";
|
||
} catch {
|
||
/* kein sessionStorage — dann bootet es jedes Mal, auch schön */
|
||
}
|
||
if (!skipBoot) await runBoot();
|
||
|
||
renderShell();
|
||
buildStartMenu();
|
||
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();
|