Files
BSO-Launcher/ui/app.js
T
MrMahoandClaude Opus 5.5 3281468bee 0.3.0: Freunde und Lobby, Server-Browser entfernt
- Seite Freunde: hinzufuegen, annehmen, online-Anzeige, Lobby erstellen/einladen/beitreten, Server starten
- SPIELEN in der Lobby: Ticket fuer die Lobby + Relay-Adresse in BSO.json
- Knopf 'Mod herunterladen' holt jederzeit die neueste Mod

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:30:22 +01:00

441 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// BSO Launcher - Oberflaeche. Spricht ueber window.chrome.webview mit dem Launcher (C#), nie direkt mit dem Netz.
// ---------------------------------------------------------------- Verbindung zum Launcher
let nextId = 1;
const pending = new Map();
const listeners = new Map();
function call(cmd, args) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
window.chrome.webview.postMessage({ id, cmd, args: args || {} });
});
}
window.chrome.webview.addEventListener("message", (e) => {
const msg = e.data;
if (msg.event) { (listeners.get(msg.event) || []).forEach((f) => f(msg)); return; }
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
msg.ok ? p.resolve(msg.result) : p.reject(new Error(msg.error));
});
function on(event, f) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(f);
}
// ---------------------------------------------------------------- Hilfen
const $ = (id) => document.getElementById(id);
function h(tag, attrs, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
else if (k === "class") el.className = v;
else el.setAttribute(k, v === true ? "" : v);
}
for (const c of children.flat()) if (c != null && c !== false) el.append(c.nodeType ? c : String(c));
return el;
}
function toast(text, error) {
const t = h("div", { class: "toast" + (error ? " error" : "") }, text);
$("toasts").append(t);
setTimeout(() => t.remove(), error ? 7000 : 4000);
}
/** Knopf waehrend einer Aktion sperren; Fehler als Meldung. */
async function busy(button, action) {
button.disabled = true;
try { return await action(); }
catch (e) { toast(e.message, true); }
finally { button.disabled = false; }
}
const date = (iso) => new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
// ---------------------------------------------------------------- Fenster
document.querySelectorAll("[data-window]").forEach((b) => b.addEventListener("click", () => call("window", { action: b.dataset.window })));
$("titlebarDrag").addEventListener("mousedown", (e) => { if (e.button === 0 && e.detail === 1) call("window", { action: "drag" }); });
$("titlebarDrag").addEventListener("dblclick", () => call("window", { action: "maximize" }));
on("window", (m) => { $("maximizeButton").innerHTML = m.maximized ? "&#xE923;" : "&#xE922;"; });
// ---------------------------------------------------------------- Anmeldung
let registerMode = false;
function setAuthMode(register) {
registerMode = register;
$("tabLogin").classList.toggle("on", !register);
$("tabRegister").classList.toggle("on", register);
$("emailRow").classList.toggle("hidden", !register);
$("password2Row").classList.toggle("hidden", !register);
$("authButton").textContent = register ? "Konto erstellen" : "Anmelden";
$("password").autocomplete = register ? "new-password" : "current-password";
$("authError").classList.add("hidden");
}
$("tabLogin").addEventListener("click", () => setAuthMode(false));
$("tabRegister").addEventListener("click", () => setAuthMode(true));
$("authForm").addEventListener("submit", async (e) => {
e.preventDefault();
const error = $("authError");
error.classList.add("hidden");
if (registerMode && $("password").value !== $("password2").value) {
error.textContent = "Die Passwoerter sind nicht gleich";
error.classList.remove("hidden");
return;
}
$("authButton").disabled = true;
try {
const r = await call(registerMode ? "register" : "login",
{ username: $("username").value, password: $("password").value, email: $("email").value });
$("password").value = $("password2").value = "";
showApp(r.username);
if (registerMode) toast("Willkommen bei BSO, " + r.username + "!");
} catch (err) {
error.textContent = err.message;
error.classList.remove("hidden");
} finally {
$("authButton").disabled = false;
}
});
$("logout").addEventListener("click", async () => {
await call("logout");
showAuth();
});
function showAuth() {
$("app").classList.add("hidden");
$("auth").classList.remove("hidden");
setAuthMode(false);
$("username").focus();
}
function showApp(username) {
$("auth").classList.add("hidden");
$("app").classList.remove("hidden");
$("meName").textContent = username;
$("meAvatar").textContent = (username || "?").slice(0, 1).toUpperCase();
showPage("home");
}
// ---------------------------------------------------------------- Seiten
let page = "home";
document.querySelectorAll("#nav [data-page]").forEach((b) => b.addEventListener("click", () => showPage(b.dataset.page)));
function showPage(name) {
page = name;
document.querySelectorAll("#nav [data-page]").forEach((b) => b.classList.toggle("on", b.dataset.page === name));
const root = $("page");
root.replaceChildren(h("p", { class: "muted" }, "Laden …"));
({ home: renderHome, friends: renderFriends, news: renderNews, settings: renderSettings })[name](root);
}
// Anmeldung abgelaufen (z.B. beim Holen des Spiel-Tickets)
on("loggedOut", () => showAuth());
// Fortschritt (Downloads)
let progressBox = null;
on("progress", (m) => {
if (!progressBox) return;
progressBox.classList.remove("hidden");
progressBox.querySelector(".bar > div").style.width = Math.round(m.value * 100) + "%";
progressBox.querySelector("span").textContent = m.text + " " + Math.round(m.value * 100) + " %";
});
async function renderHome(root) {
let s;
try { s = await call("status"); }
catch (e) { root.replaceChildren(h("p", { class: "error" }, e.message)); return; }
if (page !== "home") return;
progressBox = h("div", { class: "progress hidden" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div")));
const play = h("button", { class: "btn primary play", type: "button" }, s.game.running ? "LAEUFT" : "SPIELEN");
play.disabled = s.game.running;
play.addEventListener("click", () => busy(play, async () => {
await call("launch");
toast("Bus-Simulator 2012 startet …");
}));
const ready = s.game.found && s.dotnet && (s.mod.installed || s.mod.latest);
const line = s.mod.installed ? `BSO ${s.mod.installed} installiert · Server ${s.settings.server}` : "BSO wird beim ersten Start installiert";
const children = [
h("div", { class: "hero" },
h("div", {}, h("h1", {}, "Bus Simulator Online"), h("p", {}, "Multiplayer fuer Bus-Simulator 2012")),
h("img", { src: "logo.svg", alt: "" }),
h("div", { class: "hero-actions" }, play, h("span", { class: "muted small" }, ready ? line : "Vor dem Start fehlt noch etwas - siehe unten"))),
progressBox
];
if (s.launcherUpdate) {
const b = h("button", { class: "btn primary small", type: "button" }, "Jetzt aktualisieren");
b.addEventListener("click", () => busy(b, async () => { await call("installLauncherUpdate"); toast("Launcher startet neu …"); }));
children.push(h("div", { class: "banner" }, h("span", {}, h("b", {}, "Launcher " + s.launcherUpdate), " ist da."), b));
}
if (!s.masterReachable)
children.push(h("div", { class: "banner warn" }, h("span", {}, "BSO-Server nicht erreichbar - Updates werden spaeter geprueft.")));
children.push(h("div", { class: "cards" }, gameCard(s), modCard(s), dotnetCard(s), serverCard(s)));
root.replaceChildren(...children);
}
function card(title, state, value, ...extra) {
return h("div", { class: "card" }, h("h3", {}, h("span", { class: "dot " + state }), title), h("div", { class: "value" }, value), ...extra);
}
function gameCard(s) {
const choose = h("button", { class: "btn small", type: "button" }, s.game.found ? "Anderen Ordner waehlen" : "Ordner waehlen");
choose.addEventListener("click", () => busy(choose, async () => { if ((await call("chooseGameFolder")).changed) showPage("home"); }));
return card("Bus-Simulator 2012", s.game.found ? "ok" : "bad", s.game.found ? "Gefunden" : "Nicht gefunden",
s.game.found ? h("span", { class: "mono muted" }, s.game.folder) : h("span", { class: "muted small" }, "Ueber Steam installiert? Sonst den Ordner von Hand waehlen."),
h("div", { class: "row" }, choose));
}
function modCard(s) {
const m = s.mod;
const state = m.installed && !m.updateAvailable ? "ok" : m.latest ? "warn" : "bad";
const value = m.installed ? `Version ${m.installed}` : "Nicht installiert";
const info = m.updateAvailable ? `Neu: ${m.latest}` : m.latest ? "Aktuell" : "Keine Version auf dem Server";
const extra = [h("span", { class: "muted small" }, info)];
// Immer da: holt die neueste Mod von der Website (bei SPIELEN passiert das sonst automatisch, wenn eingeschaltet)
if (m.latest && s.game.found) {
const label = !m.installed ? "Mod herunterladen" : m.updateAvailable ? "Mod aktualisieren" : "Neu herunterladen";
const b = h("button", { class: "btn small" + (!m.installed || m.updateAvailable ? " primary" : ""), type: "button" }, label);
b.addEventListener("click", () => busy(b, async () => {
const r = await call("installMod");
toast("BSO " + r.version + " installiert");
showPage("home");
}));
extra.push(h("div", { class: "row" }, b));
}
return card("BSO-Mod", state, value, ...extra);
}
function dotnetCard(s) {
if (s.dotnet) return card(".NET 10 Runtime", "ok", "Installiert", h("span", { class: "muted small" }, "Die Mod laeuft mit .NET 10 (x64)."));
const b = h("button", { class: "btn small", type: "button" }, "Herunterladen");
b.addEventListener("click", () => call("openDotnet"));
return card(".NET 10 Runtime", "bad", "Fehlt",
h("span", { class: "muted small" }, "BSO braucht die .NET 10 Runtime (x64) - \".NET Runtime\" fuer Windows x64 installieren."),
h("div", { class: "row" }, b));
}
function serverCard(s) {
const input = h("input", { value: s.settings.server, placeholder: "host:7777", spellcheck: "false" });
const auto = h("input", { type: "checkbox" });
auto.checked = s.settings.autoConnect;
const save = () => call("setSettings", { server: input.value.trim(), autoConnect: auto.checked }).then(() => toast("Gespeichert"));
input.addEventListener("change", save);
auto.addEventListener("change", save);
return card("Server", "ok", "Mitspielen",
h("label", {}, h("span", {}, "Adresse"), input),
h("label", { class: "switch" }, h("span", {}, "Beim Start verbinden", h("small", {}, "sonst im Spiel mit F8")), auto));
}
// ---------------------------------------------------------------- Freunde & Lobby
// Die Lobby liegt auf der Website, ihr Server laeuft im BSO-Relay - keiner hostet, keiner braucht eine Portfreigabe.
let social = { friends: { friends: [], incoming: [], outgoing: [] }, lobby: { lobby: null, invites: [] } };
const seenNotices = new Set();
async function loadSocial() {
const [friends, lobby] = await Promise.all([call("friends"), call("lobby")]);
social = { friends, lobby };
const n = friends.incoming.length + lobby.invites.length;
$("friendsBadge").textContent = n;
$("friendsBadge").classList.toggle("hidden", n === 0);
// Neue Einladungen/Anfragen melden (auch auf anderen Seiten)
for (const i of lobby.invites) notice("l" + i.lobbyId, `${i.owner} lädt dich in die Lobby ein`);
for (const r of friends.incoming) notice("f" + r.id, `${r.username} möchte dein Freund sein`);
}
function notice(key, text) {
if (seenNotices.has(key)) return;
seenNotices.add(key);
if (page !== "friends") toast(text);
}
/** Alle 3 s auf der Seite Freunde, sonst alle 15 s (Badge, Meldungen) - meldet einen auch als "online". */
let socialTick = 0;
setInterval(async () => {
if ($("app").classList.contains("hidden")) return;
if (page !== "friends" && socialTick++ % 5 !== 0) return;
try { await loadSocial(); if (page === "friends") drawSocial(); } catch { }
}, 3000);
async function renderFriends(root) {
root.replaceChildren(h("h1", {}, "Freunde & Lobby"), h("div", { class: "social", id: "socialRoot" }, h("p", { class: "muted" }, "Laden …")));
try { await loadSocial(); }
catch (e) { $("socialRoot").replaceChildren(h("p", { class: "error" }, e.message)); return; }
if (page === "friends") drawSocial();
}
/** Knopf, der eine Aktion ausfuehrt und danach neu laedt. */
function action(label, cls, cmd, args, done) {
const b = h("button", { class: "btn small " + (cls || ""), type: "button" }, label);
b.addEventListener("click", () => busy(b, async () => {
await call(cmd, args || {});
if (done) done();
await loadSocial();
drawSocial();
}));
return b;
}
function drawSocial() {
const root = $("socialRoot");
if (!root) return;
root.replaceChildren(lobbyCard(), friendsCard());
}
function lobbyCard() {
const { lobby, invites } = social.lobby;
const card = h("div", { class: "card" });
for (const i of invites)
card.append(h("div", { class: "invite" }, h("span", {}, h("b", {}, i.owner), " lädt dich in die Lobby ein"),
action("Beitreten", "primary", "lobbyJoin", { lobbyId: i.lobbyId }), action("Ablehnen", "", "lobbyDecline", { lobbyId: i.lobbyId })));
if (!lobby) {
card.append(h("h3", {}, "Lobby"),
h("p", { class: "muted small" }, "Erstelle eine Lobby und lade Freunde ein. Der Server läuft auf dem BSO-Relay – niemand muss etwas hosten oder Ports freigeben."),
h("div", { class: "row" }, action("Lobby erstellen", "primary", "lobbyCreate")));
return card;
}
const play = h("button", { class: "btn primary play", type: "button" }, "SPIELEN");
play.disabled = !lobby.serverOnline;
play.addEventListener("click", () => busy(play, async () => { await call("playLobby"); toast("Bus-Simulator 2012 startet und verbindet mit der Lobby …"); }));
const state = h("div", { class: "server-state" },
h("span", { class: "dot " + (lobby.serverOnline ? "ok" : "bad") }),
h("span", { style: "flex:1" }, lobby.serverOnline ? "Server läuft" : lobby.isOwner ? "Server aus – starte ihn, dann können alle spielen" : `Server aus – warte, bis ${lobby.owner} ihn startet`),
lobby.isOwner ? (lobby.serverOnline ? action("Server stoppen", "", "lobbyStop") : action("Server starten", "primary", "lobbyStart")) : null);
const members = h("div", { class: "people" }, ...lobby.members.map((m) => h("div", { class: "person" },
h("span", { class: "dot " + (m.online ? "ok" : "") }),
h("span", { class: "name" }, m.username),
m.isOwner ? h("span", { class: "crown", title: "Ersteller" }, "★") : null,
lobby.isOwner && !m.isOwner ? action("Entfernen", "ghost", "lobbyKick", { accountId: m.accountId }) : null)));
card.append(h("h3", {}, lobby.isOwner ? "Deine Lobby" : `Lobby von ${lobby.owner}`), state,
h("div", { class: "section-title" }, `Mitglieder (${lobby.members.length})`), members,
lobby.invited.length ? h("p", { class: "muted small" }, `${lobby.invited.length} Einladung(en) offen`) : null,
h("div", { class: "row", style: "justify-content:space-between;margin-top:6px" }, play,
action(lobby.isOwner ? "Lobby auflösen" : "Lobby verlassen", "danger", "lobbyLeave")));
return card;
}
function friendsCard() {
const { friends, incoming, outgoing } = social.friends;
const lobby = social.lobby.lobby;
const name = h("input", { placeholder: "Benutzername", maxlength: "32", spellcheck: "false" });
const add = h("button", { class: "btn small primary", type: "button" }, "Hinzufügen");
const send = () => busy(add, async () => {
if (!name.value.trim()) return;
await call("friendRequest", { username: name.value.trim() });
toast("Anfrage an " + name.value.trim() + " geschickt");
name.value = "";
await loadSocial();
drawSocial();
});
add.addEventListener("click", send);
name.addEventListener("keydown", (e) => { if (e.key === "Enter") send(); });
const card = h("div", { class: "card" }, h("h3", {}, "Freunde"), h("div", { class: "row" }, name, add));
if (incoming.length)
card.append(h("div", { class: "section-title" }, "Anfragen"), h("div", { class: "people" }, ...incoming.map((r) => h("div", { class: "person" },
h("span", { class: "name" }, r.username), action("Annehmen", "primary", "friendAccept", { id: r.id }), action("Ablehnen", "", "friendRemove", { id: r.id })))));
card.append(h("div", { class: "section-title" }, `Freunde (${friends.filter((f) => f.online).length} online)`));
if (!friends.length) card.append(h("p", { class: "muted small" }, "Noch keine Freunde – füge jemanden über seinen BSO-Benutzernamen hinzu."));
card.append(h("div", { class: "people" }, ...friends.map((f) => {
const inLobby = lobby && lobby.members.some((m) => m.accountId === f.accountId);
const invited = lobby && lobby.invited.includes(f.accountId);
const remove = h("button", { class: "x", type: "button", title: "Freund entfernen" }, "");
remove.addEventListener("click", () => { if (confirm(`${f.username} als Freund entfernen?`)) busy(remove, async () => { await call("friendRemove", { id: f.id }); await loadSocial(); drawSocial(); }); });
return h("div", { class: "person" },
h("span", { class: "dot " + (f.online ? "ok" : "") }), h("span", { class: "name" }, f.username),
lobby && lobby.isOwner && !inLobby ? (invited ? h("span", { class: "muted small" }, "eingeladen") : action("Einladen", "", "lobbyInvite", { accountId: f.accountId })) : null,
inLobby ? h("span", { class: "muted small" }, "in der Lobby") : null,
remove);
})));
if (outgoing.length)
card.append(h("div", { class: "section-title" }, "Gesendet"), h("div", { class: "people" }, ...outgoing.map((r) => h("div", { class: "person" },
h("span", { class: "name" }, r.username), h("span", { class: "muted small" }, "wartet …"), action("Zurückziehen", "ghost", "friendRemove", { id: r.id })))));
return card;
}
async function renderNews(root) {
let list;
try { list = await call("releases"); }
catch (e) { root.replaceChildren(h("h1", {}, "Neuigkeiten"), h("p", { class: "error" }, e.message)); return; }
if (page !== "news") return;
const names = { mod: "Mod", launcher: "Launcher", server: "Server" };
root.replaceChildren(h("h1", {}, "Neuigkeiten"),
...(list.length ? list.map((r) => h("article", { class: "release" },
h("header", {}, h("span", { class: "tag" }, names[r.product] || r.product), h("h2", {}, r.version), h("span", { class: "muted small" }, date(r.date))),
h("pre", {}, r.changelog || "Keine Beschreibung"))) : [h("p", { class: "muted" }, "Noch keine Versionen veroeffentlicht.")]));
}
async function renderSettings(root) {
const [init, s] = await Promise.all([call("init"), call("status").catch(() => null)]);
if (page !== "settings") return;
const toggle = (label, hint, key, value) => {
const box = h("input", { type: "checkbox" });
box.checked = value;
box.addEventListener("change", () => call("setSettings", { [key]: box.checked }).then(() => toast("Gespeichert")));
return h("label", { class: "switch" }, h("span", {}, label, h("small", {}, hint)), box);
};
const folder = h("button", { class: "btn small", type: "button" }, "Spielordner waehlen");
folder.addEventListener("click", () => busy(folder, async () => { if ((await call("chooseGameFolder")).changed) { toast("Spielordner gespeichert"); showPage("settings"); } }));
const log = h("button", { class: "btn small", type: "button" }, "Protokoll-Ordner oeffnen");
log.addEventListener("click", () => call("openLog"));
const uninstall = h("button", { class: "btn small danger", type: "button" }, "BSO deinstallieren");
uninstall.addEventListener("click", () => busy(uninstall, () => call("uninstall")));
root.replaceChildren(h("h1", {}, "Einstellungen"), h("div", { class: "settings" },
h("div", { class: "card" },
toggle("BSO automatisch aktualisieren", "Vor jedem Start die neueste Mod-Version installieren", "autoUpdateMod", s?.settings.autoUpdateMod ?? true),
toggle("Launcher offen lassen", "Sonst schliesst er sich nach dem Spielstart", "keepOpen", s?.settings.keepOpen ?? false)),
h("div", { class: "card" },
h("h3", {}, "Spiel"),
h("span", { class: "mono muted" }, s?.game.folder || "Nicht gefunden"),
h("div", { class: "row" }, folder)),
h("div", { class: "card" },
h("h3", {}, "Launcher"),
h("span", { class: "muted small" }, `Version ${init.version} · Server ${init.master}`),
h("div", { class: "row" }, log, uninstall))));
}
// ---------------------------------------------------------------- Start
(async () => {
const init = await call("init");
$("meVersion").textContent = "Launcher " + init.version;
if (init.dev) {
// -dev: gegen die lokale Test-Website - gut sichtbar, damit niemand das verwechselt
const host = init.master.replace(/^https?:\/\//, "");
$("devBadge").textContent = "DEV · " + host;
$("devBadge").classList.remove("hidden");
$("meVersion").textContent += " · DEV";
}
$("authServer").textContent = "Server: " + init.master.replace(/^https?:\/\//, "");
if (!init.loggedIn) { showAuth(); return; }
const me = await call("me");
if (me.loggedIn) showApp(me.username);
else showAuth();
})();