"use strict"; // BSO Launcher - Oberflaeche. Spricht ueber window.chrome.webview mit dem Launcher (C#), nie direkt mit dem Netz. // Texte auf Deutsch im Code, t() uebersetzt (i18n.js). // ---------------------------------------------------------------- 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 el = h("div", { class: "toast" + (error ? " error" : "") }, text); $("toasts").append(el); setTimeout(() => el.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(lang === "en" ? "en-GB" : "de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); // ---------------------------------------------------------------- Sprache /** Sprache wechseln: im Launcher speichern (auch fuer seine eigenen Meldungen), alles neu beschriften. */ async function setLang(l) { lang = l; await call("setSettings", { language: l }); translateStatic(); renderLangSwitch(setLang); setAuthMode(registerMode); if (!$("app").classList.contains("hidden")) showPage(page); } // ---------------------------------------------------------------- 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 ? "" : ""; }); // ---------------------------------------------------------------- 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 ? t("Konto erstellen") : t("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 = t("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(t("Willkommen bei BSO, {0}!", 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" }, t("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) - der Text kommt schon uebersetzt vom Launcher 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 ? t("LAEUFT") : t("SPIELEN")); play.disabled = s.game.running; play.addEventListener("click", () => busy(play, async () => { await call("launch"); toast(t("Bus-Simulator 2012 startet …")); })); const ready = s.game.found && s.dotnet && (s.mod.installed || s.mod.latest); const line = s.mod.installed ? t("BSO {0} installiert · Server {1}", s.mod.installed, s.settings.server) : t("BSO wird beim ersten Start installiert"); const children = [ h("div", { class: "hero" }, h("div", {}, h("h1", {}, "Bus Simulator Online"), h("p", {}, t("Multiplayer fuer Bus-Simulator 2012"))), h("img", { src: "logo.svg", alt: "" }), h("div", { class: "hero-actions" }, play, h("span", { class: "muted small" }, ready ? line : t("Vor dem Start fehlt noch etwas - siehe unten")))), progressBox ]; if (s.launcherUpdate) { const b = h("button", { class: "btn primary small", type: "button" }, t("Jetzt aktualisieren")); b.addEventListener("click", () => busy(b, async () => { await call("installLauncherUpdate"); toast(t("Launcher startet neu …")); })); children.push(h("div", { class: "banner" }, h("span", {}, h("b", {}, t("Launcher {0}", s.launcherUpdate)), t(" ist da.")), b)); } if (!s.masterReachable) children.push(h("div", { class: "banner warn" }, h("span", {}, t("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 ? t("Anderen Ordner waehlen") : t("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 ? t("Gefunden") : t("Nicht gefunden"), s.game.found ? h("span", { class: "mono muted" }, s.game.folder) : h("span", { class: "muted small" }, t("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 ? t("Version {0}", m.installed) : t("Nicht installiert"); const info = m.updateAvailable ? t("Neu: {0}", m.latest) : m.latest ? t("Aktuell") : t("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 ? t("Mod herunterladen") : m.updateAvailable ? t("Mod aktualisieren") : t("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(t("BSO {0} installiert", r.version)); 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", t("Installiert"), h("span", { class: "muted small" }, t("Die Mod laeuft mit .NET 10 (x64)."))); const b = h("button", { class: "btn small", type: "button" }, t("Herunterladen")); b.addEventListener("click", () => call("openDotnet")); return card(".NET 10 Runtime", "bad", t("Fehlt"), h("span", { class: "muted small" }, t("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(t("Gespeichert"))); input.addEventListener("change", save); auto.addEventListener("change", save); return card("Server", "ok", t("Mitspielen"), h("label", {}, h("span", {}, t("Adresse")), input), h("label", { class: "switch" }, h("span", {}, t("Beim Start verbinden"), h("small", {}, t("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, t("{0} lädt dich in die Lobby ein", i.owner)); for (const r of friends.incoming) notice("f" + r.id, t("{0} möchte dein Freund sein", r.username)); } 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", {}, t("Freunde & Lobby")), h("div", { class: "social", id: "socialRoot" }, h("p", { class: "muted" }, t("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), t(" lädt dich in die Lobby ein")), action(t("Beitreten"), "primary", "lobbyJoin", { lobbyId: i.lobbyId }), action(t("Ablehnen"), "", "lobbyDecline", { lobbyId: i.lobbyId }))); if (!lobby) { card.append(h("h3", {}, t("Lobby")), h("p", { class: "muted small" }, t("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(t("Lobby erstellen"), "primary", "lobbyCreate"))); return card; } const play = h("button", { class: "btn primary play", type: "button" }, t("SPIELEN")); // Beim Ersteller startet SPIELEN den Lobby-Server gleich mit, die anderen koennen spielen, sobald er laeuft play.disabled = !lobby.serverOnline && !lobby.isOwner; play.addEventListener("click", () => busy(play, async () => { await call("playLobby", { startServer: lobby.isOwner && !lobby.serverOnline }); toast(t("Bus-Simulator 2012 startet und verbindet mit der Lobby …")); await loadSocial(); drawSocial(); })); const state = h("div", { class: "server-state" }, h("span", { class: "dot " + (lobby.serverOnline ? "ok" : "bad") }), h("span", { style: "flex:1" }, lobby.serverOnline ? t("Server läuft – alle können SPIELEN drücken") : lobby.isOwner ? t("Drück SPIELEN – der Server startet dann für die ganze Lobby") : t("Warte, bis {0} SPIELEN drückt – dann startet der Server", lobby.owner)), lobby.isOwner && lobby.serverOnline ? action(t("Server stoppen"), "", "lobbyStop") : 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: t("Ersteller") }, "★") : null, lobby.isOwner && !m.isOwner ? action(t("Entfernen"), "ghost", "lobbyKick", { accountId: m.accountId }) : null))); card.append(h("h3", {}, lobby.isOwner ? t("Deine Lobby") : t("Lobby von {0}", lobby.owner)), state, h("div", { class: "section-title" }, t("Mitglieder ({0})", lobby.members.length)), members, lobby.invited.length ? h("p", { class: "muted small" }, t("{0} Einladung(en) offen", lobby.invited.length)) : null, h("div", { class: "row", style: "justify-content:space-between;margin-top:6px" }, play, action(lobby.isOwner ? t("Lobby auflösen") : t("Lobby verlassen"), "danger", "lobbyLeave"))); return card; } function friendsCard() { const { friends, incoming, outgoing } = social.friends; const lobby = social.lobby.lobby; const name = h("input", { placeholder: t("Benutzername"), maxlength: "32", spellcheck: "false" }); const add = h("button", { class: "btn small primary", type: "button" }, t("Hinzufügen")); const send = () => busy(add, async () => { if (!name.value.trim()) return; await call("friendRequest", { username: name.value.trim() }); toast(t("Anfrage an {0} geschickt", name.value.trim())); 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", {}, t("Freunde")), h("div", { class: "row" }, name, add)); if (incoming.length) card.append(h("div", { class: "section-title" }, t("Anfragen")), h("div", { class: "people" }, ...incoming.map((r) => h("div", { class: "person" }, h("span", { class: "name" }, r.username), action(t("Annehmen"), "primary", "friendAccept", { id: r.id }), action(t("Ablehnen"), "", "friendRemove", { id: r.id }))))); card.append(h("div", { class: "section-title" }, t("Freunde ({0} online)", friends.filter((f) => f.online).length))); if (!friends.length) card.append(h("p", { class: "muted small" }, t("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: t("Freund entfernen") }, ""); remove.addEventListener("click", () => { if (confirm(t("{0} als Freund entfernen?", f.username))) 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" }, t("eingeladen")) : action(t("Einladen"), "", "lobbyInvite", { accountId: f.accountId })) : null, inLobby ? h("span", { class: "muted small" }, t("in der Lobby")) : null, remove); }))); if (outgoing.length) card.append(h("div", { class: "section-title" }, t("Gesendet")), h("div", { class: "people" }, ...outgoing.map((r) => h("div", { class: "person" }, h("span", { class: "name" }, r.username), h("span", { class: "muted small" }, t("wartet …")), action(t("Zurückziehen"), "ghost", "friendRemove", { id: r.id }))))); return card; } // ---------------------------------------------------------------- Neuigkeiten, Einstellungen async function renderNews(root) { let list; try { list = await call("releases"); } catch (e) { root.replaceChildren(h("h1", {}, t("Neuigkeiten")), h("p", { class: "error" }, e.message)); return; } if (page !== "news") return; const names = { mod: "Mod", launcher: "Launcher", server: "Server" }; list = list.filter((r) => r.product !== "server"); // Server laufen jetzt im BSO-Relay root.replaceChildren(h("h1", {}, t("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 || t("Keine Beschreibung")))) : [h("p", { class: "muted" }, t("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(t("Gespeichert")))); return h("label", { class: "switch" }, h("span", {}, label, h("small", {}, hint)), box); }; const folder = h("button", { class: "btn small", type: "button" }, t("Spielordner waehlen")); folder.addEventListener("click", () => busy(folder, async () => { if ((await call("chooseGameFolder")).changed) { toast(t("Spielordner gespeichert")); showPage("settings"); } })); const log = h("button", { class: "btn small", type: "button" }, t("Protokoll-Ordner oeffnen")); log.addEventListener("click", () => call("openLog")); const uninstall = h("button", { class: "btn small danger", type: "button" }, t("BSO deinstallieren")); uninstall.addEventListener("click", () => busy(uninstall, () => call("uninstall"))); const langBox = h("div", { class: "lang-switch", "data-lang-switch": true }); root.replaceChildren(h("h1", {}, t("Einstellungen")), h("div", { class: "settings" }, h("div", { class: "card" }, toggle(t("BSO automatisch aktualisieren"), t("Vor jedem Start die neueste Mod-Version installieren"), "autoUpdateMod", s?.settings.autoUpdateMod ?? true), toggle(t("Launcher offen lassen"), t("Sonst schliesst er sich nach dem Spielstart"), "keepOpen", s?.settings.keepOpen ?? false), h("div", { class: "switch" }, h("span", {}, t("Sprache")), langBox)), h("div", { class: "card" }, h("h3", {}, t("Spiel")), h("span", { class: "mono muted" }, s?.game.folder || t("Nicht gefunden")), h("div", { class: "row" }, folder)), h("div", { class: "card" }, h("h3", {}, "Launcher"), h("span", { class: "muted small" }, t("Version {0} · Server {1}", init.version, init.master)), h("div", { class: "row" }, log, uninstall)))); renderLangSwitch(setLang); } // ---------------------------------------------------------------- Update-Abfrage /** Bei jedem Start: gibt es einen neuen Launcher (oder eine neue Mod)? Dann fragen - "Später" fragt beim naechsten Start wieder. */ async function askForUpdates() { let s; try { s = await call("status"); } catch { return; } const mod = s.mod.installed && s.mod.updateAvailable && !s.game.running ? s.mod : null; if (!s.launcherUpdate && !mod) return; const item = (name, from, to, changelog) => h("div", { class: "update-item" }, h("div", {}, h("b", {}, name + " " + to), h("span", { class: "muted small" }, " " + t("installiert: {0}", from))), h("pre", {}, changelog || t("Keine Beschreibung"))); const progress = h("div", { class: "progress hidden" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div"))); const later = h("button", { class: "btn", type: "button" }, t("Später")); const now = h("button", { class: "btn primary", type: "button" }, t("Jetzt aktualisieren")); const box = h("div", { class: "modal-back" }, h("div", { class: "modal", role: "dialog" }, h("h2", {}, t("Update verfügbar")), h("p", { class: "muted" }, t("Beim Start gefunden – jetzt aktualisieren?")), s.launcherUpdate ? item("Launcher", s.launcherVersion, s.launcherUpdate, s.launcherChangelog) : null, mod ? item(t("BSO-Mod"), mod.installed, mod.latest, mod.changelog) : null, progress, h("div", { class: "row", style: "justify-content:flex-end" }, later, now))); document.body.append(box); now.focus(); later.addEventListener("click", () => box.remove()); now.addEventListener("click", async () => { now.disabled = later.disabled = true; progressBox = progress; try { if (mod) await call("installMod"); if (s.launcherUpdate) { await call("installLauncherUpdate"); toast(t("Launcher startet neu …")); return; } // beendet sich gleich box.remove(); toast(t("BSO {0} installiert", mod.latest)); if (!$("app").classList.contains("hidden")) showPage(page); } catch (e) { toast(e.message, true); now.disabled = later.disabled = false; } }); } // ---------------------------------------------------------------- Start (async () => { const init = await call("init"); lang = init.language === "en" ? "en" : "de"; translateStatic(); renderLangSwitch(setLang); setAuthMode(false); $("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 = t("Server: {0}", init.master.replace(/^https?:\/\//, "")); askForUpdates(); // auch vor dem Anmelden if (!init.loggedIn) { showAuth(); return; } const me = await call("me"); if (me.loggedIn) showApp(me.username); else showAuth(); })();