@@ -0,0 +1,6 @@
|
||||
bin/
|
||||
obj/
|
||||
dist/
|
||||
publish/
|
||||
.vs/
|
||||
*.user
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>BSO.Launcher</RootNamespace>
|
||||
<AssemblyName>BSO-Launcher</AssemblyName>
|
||||
<Version>0.1.0</Version>
|
||||
<Company>MBM.wtf</Company>
|
||||
<Product>BSO Launcher</Product>
|
||||
<Description>BSO Launcher - Bus Simulator Online</Description>
|
||||
<ApplicationIcon>logo.ico</ApplicationIcon>
|
||||
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||
<!-- Eine einzelne .exe ohne vorinstalliertes .NET (wie der MSCO Launcher) -->
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4258.31" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Oberflaeche (HTML/CSS/JS), wird aus der .exe ausgeliefert -->
|
||||
<EmbeddedResource Include="ui\**\*" LogicalName="ui/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Web.WebView2.WinForms;
|
||||
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster mit WebView2 (wie beim MSCO Launcher): die Oberflaeche (ui/*.html/css/js, in der .exe eingebettet) laeuft unter
|
||||
/// https://app.bso/ und spricht ueber Nachrichten { id, cmd, args } mit dem Launcher; Antworten { id, ok, result | error },
|
||||
/// Ereignisse { event, ... } (z.B. Download-Fortschritt).
|
||||
/// </summary>
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
const string Host = "app.bso";
|
||||
readonly WebView2 web = new() { Dock = DockStyle.Fill, DefaultBackgroundColor = Color.FromArgb(0x0b, 0x0d, 0x12) };
|
||||
readonly Settings settings = Settings.Load();
|
||||
|
||||
/// <summary>Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).</summary>
|
||||
static readonly HashSet<string> Exclusive = new() { "installMod", "launch", "installLauncherUpdate", "uninstall" };
|
||||
readonly HashSet<string> running = new();
|
||||
static readonly HashSet<string> QuietCommands = new() { "window", "status", "me", "init", "releases" };
|
||||
bool updateInstalled;
|
||||
static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "BSO Launcher";
|
||||
BackColor = Color.FromArgb(0x0b, 0x0d, 0x12);
|
||||
ClientSize = new Size(1120, 700);
|
||||
MinimumSize = new Size(900, 600);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
LauncherLog.Info("BSO Launcher " + SelfUpdate.CurrentVersion + " gestartet");
|
||||
try { Icon = Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? Application.ExecutablePath); } catch { }
|
||||
Controls.Add(web);
|
||||
Load += async (_, _) => await InitWeb();
|
||||
Resize += (_, _) => Emit(new { @event = "window", maximized = WindowState == FormWindowState.Maximized });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ohne Windows-Titelleiste
|
||||
// Die Titelleiste zeichnet die Oberflaeche selbst (ziehen, minimieren, maximieren, schliessen). Der Rahmen bleibt:
|
||||
// das Fenster laesst sich weiter mit der Maus vergroessern, Aero Snap und Schatten bleiben.
|
||||
|
||||
const int WM_NCCALCSIZE = 0x0083, WM_NCLBUTTONDOWN = 0x00A1, HTCAPTION = 2;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
[DllImport("user32.dll")] static extern bool ReleaseCapture();
|
||||
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||
[DllImport("user32.dll")] static extern int GetSystemMetricsForDpi(int index, uint dpi);
|
||||
[DllImport("user32.dll")] static extern uint GetDpiForWindow(IntPtr hwnd);
|
||||
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
||||
[DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
try
|
||||
{
|
||||
int dark = 1, round = 2; // dunkler Rahmen, abgerundete Ecken (Windows 11)
|
||||
DwmSetWindowAttribute(Handle, 20, ref dark, 4);
|
||||
DwmSetWindowAttribute(Handle, 33, ref round, 4);
|
||||
}
|
||||
catch { }
|
||||
SetWindowPos(Handle, IntPtr.Zero, 0, 0, 0, 0, 0x0020 | 0x0002 | 0x0001 | 0x0004); // FRAMECHANGED|NOMOVE|NOSIZE|NOZORDER
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
if (m.Msg == WM_NCCALCSIZE && m.WParam != IntPtr.Zero)
|
||||
{
|
||||
// Nur die Titelleiste wegnehmen: oben beginnt der Inhalt direkt am Fensterrand
|
||||
var before = Marshal.PtrToStructure<RECT>(m.LParam);
|
||||
base.WndProc(ref m);
|
||||
var after = Marshal.PtrToStructure<RECT>(m.LParam);
|
||||
after.Top = before.Top;
|
||||
if (WindowState == FormWindowState.Maximized)
|
||||
{
|
||||
// Maximiert ragt das Fenster um den Rahmen ueber den Bildschirm hinaus
|
||||
uint dpi = GetDpiForWindow(Handle);
|
||||
after.Top += GetSystemMetricsForDpi(33, dpi) + GetSystemMetricsForDpi(92, dpi); // SM_CYFRAME + SM_CXPADDEDBORDER
|
||||
}
|
||||
Marshal.StructureToPtr(after, m.LParam, false);
|
||||
m.Result = IntPtr.Zero;
|
||||
return;
|
||||
}
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
void WindowCommand(string action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "drag":
|
||||
ReleaseCapture();
|
||||
SendMessage(Handle, WM_NCLBUTTONDOWN, HTCAPTION, IntPtr.Zero);
|
||||
break;
|
||||
case "minimize":
|
||||
WindowState = FormWindowState.Minimized;
|
||||
break;
|
||||
case "maximize":
|
||||
WindowState = WindowState == FormWindowState.Maximized ? FormWindowState.Normal : FormWindowState.Maximized;
|
||||
break;
|
||||
case "close":
|
||||
Close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async Task InitWeb()
|
||||
{
|
||||
try
|
||||
{
|
||||
var env = await CoreWebView2Environment.CreateAsync(null, Path.Combine(Settings.Folder, "WebView2"));
|
||||
await web.EnsureCoreWebView2Async(env);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var answer = MessageBox.Show("Die Microsoft Edge WebView2 Runtime fehlt (" + e.Message + ").\n\nDownload-Seite oeffnen?",
|
||||
Installer.AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Error);
|
||||
if (answer == DialogResult.Yes) OpenUrl("https://developer.microsoft.com/microsoft-edge/webview2/");
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
var core = web.CoreWebView2;
|
||||
core.Settings.AreDevToolsEnabled = Debugger.IsAttached;
|
||||
core.Settings.AreDefaultContextMenusEnabled = Debugger.IsAttached;
|
||||
core.Settings.IsZoomControlEnabled = false;
|
||||
core.Settings.IsStatusBarEnabled = false;
|
||||
core.Settings.AreBrowserAcceleratorKeysEnabled = Debugger.IsAttached;
|
||||
try { core.Settings.IsNonClientRegionSupportEnabled = true; } catch { } // CSS "app-region: drag" = Titelleiste
|
||||
core.AddWebResourceRequestedFilter($"https://{Host}/*", CoreWebView2WebResourceContext.All);
|
||||
core.WebResourceRequested += ServeUi;
|
||||
// Befehle nur von der eigenen Oberflaeche (nicht aus fremden Seiten oder eingebetteten Frames)
|
||||
core.WebMessageReceived += async (_, e) =>
|
||||
{
|
||||
if (!e.Source.StartsWith($"https://{Host}/", StringComparison.Ordinal)) return;
|
||||
await OnMessage(e.WebMessageAsJson);
|
||||
};
|
||||
// Links nach aussen im Browser oeffnen, nicht im Launcher
|
||||
core.NewWindowRequested += (_, e) => { e.Handled = true; OpenUrl(e.Uri); };
|
||||
core.NavigationStarting += (_, e) =>
|
||||
{
|
||||
if (!e.Uri.StartsWith($"https://{Host}/")) { e.Cancel = true; OpenUrl(e.Uri); }
|
||||
};
|
||||
core.Navigate($"https://{Host}/index.html");
|
||||
}
|
||||
|
||||
static void OpenUrl(string url)
|
||||
{
|
||||
if (url.StartsWith("https://") || url.StartsWith("http://"))
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
void ServeUi(object? sender, CoreWebView2WebResourceRequestedEventArgs e)
|
||||
{
|
||||
string path = new Uri(e.Request.Uri).AbsolutePath.TrimStart('/');
|
||||
if (path.Length == 0) path = "index.html";
|
||||
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("ui/" + path);
|
||||
if (stream == null)
|
||||
{
|
||||
e.Response = web.CoreWebView2.Environment.CreateWebResourceResponse(null, 404, "Not found", "");
|
||||
return;
|
||||
}
|
||||
string type = Path.GetExtension(path) switch
|
||||
{
|
||||
".html" => "text/html; charset=utf-8",
|
||||
".js" => "text/javascript; charset=utf-8",
|
||||
".css" => "text/css; charset=utf-8",
|
||||
".png" => "image/png",
|
||||
".svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
e.Response = web.CoreWebView2.Environment.CreateWebResourceResponse(stream, 200, "OK",
|
||||
$"Content-Type: {type}\nCache-Control: no-store\nX-Content-Type-Options: nosniff\nContent-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Nachrichten aus der Oberflaeche
|
||||
|
||||
void Emit(object message)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
string json = JsonSerializer.Serialize(message, Json);
|
||||
if (InvokeRequired) BeginInvoke(() => web.CoreWebView2?.PostWebMessageAsJson(json));
|
||||
else web.CoreWebView2?.PostWebMessageAsJson(json);
|
||||
}
|
||||
|
||||
void Progress(string task, double value, string text) => Emit(new { @event = "progress", task, value, text });
|
||||
|
||||
async Task OnMessage(string raw)
|
||||
{
|
||||
int id = 0;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var msg = doc.RootElement;
|
||||
id = msg.GetProperty("id").GetInt32();
|
||||
string cmd = msg.GetProperty("cmd").GetString() ?? "";
|
||||
JsonElement args = msg.TryGetProperty("args", out var a) ? a.Clone() : default;
|
||||
if (!QuietCommands.Contains(cmd)) LauncherLog.Info("> " + cmd);
|
||||
object? result;
|
||||
if (Exclusive.Contains(cmd))
|
||||
{
|
||||
lock (running)
|
||||
if (!running.Add(cmd)) throw new Exception("Laeuft schon - bitte warten");
|
||||
try { result = await HandleCommand(cmd, args); }
|
||||
finally
|
||||
{
|
||||
// Launcher-Update erfolgreich: der Launcher beendet sich gleich - nicht wieder freigeben
|
||||
if (cmd != "installLauncherUpdate" || !updateInstalled) lock (running) running.Remove(cmd);
|
||||
}
|
||||
}
|
||||
else result = await HandleCommand(cmd, args);
|
||||
Emit(new { id, ok = true, result });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LauncherLog.Error(e.Message);
|
||||
Emit(new { id, ok = false, error = e.Message });
|
||||
}
|
||||
}
|
||||
|
||||
static string Arg(JsonElement args, string name) =>
|
||||
args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
|
||||
|
||||
static bool? BoolArg(JsonElement args, string name) =>
|
||||
args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? v.GetBoolean() : null;
|
||||
|
||||
async Task<object?> HandleCommand(string cmd, JsonElement args)
|
||||
{
|
||||
switch (cmd)
|
||||
{
|
||||
case "window":
|
||||
WindowCommand(Arg(args, "action"));
|
||||
return null;
|
||||
case "init":
|
||||
return new
|
||||
{
|
||||
maximized = WindowState == FormWindowState.Maximized,
|
||||
version = SelfUpdate.CurrentVersion,
|
||||
username = settings.Username,
|
||||
loggedIn = settings.Token != null,
|
||||
master = Api.Base
|
||||
};
|
||||
|
||||
// --- Konto
|
||||
case "login":
|
||||
case "register":
|
||||
{
|
||||
var r = cmd == "login"
|
||||
? await Api.Login(Arg(args, "username"), Arg(args, "password"))
|
||||
: await Api.Register(Arg(args, "username"), Arg(args, "password"), Arg(args, "email") is { Length: > 0 } mail ? mail : null);
|
||||
if (!r.Ok) throw new Exception(r.Error ?? "Fehlgeschlagen");
|
||||
settings.Token = r.Json.GetProperty("token").GetString();
|
||||
settings.Username = r.Json.GetProperty("account").GetProperty("username").GetString();
|
||||
settings.Save();
|
||||
LauncherLog.Info((cmd == "login" ? "Angemeldet als " : "Registriert als ") + settings.Username);
|
||||
return new { username = settings.Username };
|
||||
}
|
||||
case "me":
|
||||
{
|
||||
string? token = settings.Token;
|
||||
if (token == null) return new { loggedIn = false };
|
||||
var r = await Api.Me(token);
|
||||
if (r.Status == 401) { settings.Token = null; settings.Save(); return new { loggedIn = false }; }
|
||||
// Master gerade nicht erreichbar: angemeldet bleiben
|
||||
return new { loggedIn = true, username = r.Ok ? r.Json.GetProperty("account").GetProperty("username").GetString() : settings.Username, offline = !r.Ok };
|
||||
}
|
||||
case "logout":
|
||||
{
|
||||
string? token = settings.Token;
|
||||
if (token != null) await Api.Logout(token);
|
||||
settings.Token = null;
|
||||
settings.Save();
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Status, Installieren, Spielen
|
||||
case "status":
|
||||
return await Status();
|
||||
case "installMod":
|
||||
return await InstallMod();
|
||||
case "launch":
|
||||
return await Launch();
|
||||
case "releases":
|
||||
{
|
||||
var r = await Api.Releases();
|
||||
if (!r.Ok) throw new Exception(r.Error ?? "Neuigkeiten nicht verfuegbar");
|
||||
return r.Json.GetProperty("releases");
|
||||
}
|
||||
case "installLauncherUpdate":
|
||||
{
|
||||
var latest = await Api.Latest("launcher") ?? throw new Exception("Kein Launcher-Update gefunden");
|
||||
string version = await SelfUpdate.Install(latest, p => Progress("launcher", p, "Launcher-Update wird geladen …"));
|
||||
updateInstalled = true;
|
||||
LauncherLog.Info("Launcher aktualisiert auf " + version);
|
||||
BeginInvoke(Close); // die neue Version startet gerade
|
||||
return new { version };
|
||||
}
|
||||
|
||||
// --- Einstellungen
|
||||
case "setSettings":
|
||||
if (args.ValueKind == JsonValueKind.Object && args.TryGetProperty("server", out var s) && s.ValueKind == JsonValueKind.String)
|
||||
settings.Server = s.GetString()!.Trim();
|
||||
settings.AutoConnect = BoolArg(args, "autoConnect") ?? settings.AutoConnect;
|
||||
settings.KeepOpen = BoolArg(args, "keepOpen") ?? settings.KeepOpen;
|
||||
settings.AutoUpdateMod = BoolArg(args, "autoUpdateMod") ?? settings.AutoUpdateMod;
|
||||
settings.Save();
|
||||
return null;
|
||||
case "chooseGameFolder":
|
||||
{
|
||||
using var dialog = new FolderBrowserDialog { Description = "Ordner \"Bus-Simulator 2012\" waehlen", UseDescriptionForTitle = true };
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK) return new { changed = false };
|
||||
string folder = dialog.SelectedPath;
|
||||
// Auch Bin_High_Win64 selbst gewaehlt? Dann den Ordner darueber nehmen
|
||||
if (!Game.IsGameFolder(folder) && Game.IsGameFolder(Path.GetDirectoryName(folder))) folder = Path.GetDirectoryName(folder)!;
|
||||
if (!Game.IsGameFolder(folder)) throw new Exception("In diesem Ordner ist kein Bus-Simulator 2012 (Bin_High_Win64\\BusSimulator2012.exe fehlt)");
|
||||
settings.GameFolder = folder;
|
||||
settings.Save();
|
||||
return new { changed = true };
|
||||
}
|
||||
case "openDotnet":
|
||||
OpenUrl(Game.DotnetDownload);
|
||||
return null;
|
||||
case "openLog":
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{Settings.Folder}\"") { UseShellExecute = true });
|
||||
return null;
|
||||
case "uninstall":
|
||||
if (Installer.Uninstall(false)) BeginInvoke(Close);
|
||||
return null;
|
||||
}
|
||||
throw new Exception("Unbekannter Befehl: " + cmd);
|
||||
}
|
||||
|
||||
async Task<object> Status()
|
||||
{
|
||||
string? folder = Game.FindFolder(settings);
|
||||
var modTask = Api.Latest("mod");
|
||||
var launcherTask = Api.Latest("launcher");
|
||||
await Task.WhenAll(modTask, launcherTask);
|
||||
Api.Release? mod = modTask.Result, launcher = launcherTask.Result;
|
||||
string? installed = folder != null ? Game.ModVersion(folder) : null;
|
||||
return new
|
||||
{
|
||||
game = new { found = folder != null, folder, running = Game.IsRunning() },
|
||||
mod = new
|
||||
{
|
||||
installed,
|
||||
latest = mod?.version,
|
||||
changelog = mod?.changelog,
|
||||
updateAvailable = mod != null && (installed == null || SelfUpdate.IsNewer(mod.version, installed))
|
||||
},
|
||||
dotnet = Game.DotnetInstalled(),
|
||||
launcherUpdate = launcher != null && SelfUpdate.IsNewer(launcher.version, SelfUpdate.CurrentVersion) ? launcher.version : null,
|
||||
masterReachable = mod != null || launcher != null,
|
||||
settings = new { server = settings.Server, autoConnect = settings.AutoConnect, keepOpen = settings.KeepOpen, autoUpdateMod = settings.AutoUpdateMod }
|
||||
};
|
||||
}
|
||||
|
||||
async Task<object> InstallMod()
|
||||
{
|
||||
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen");
|
||||
var latest = await Api.Latest("mod") ?? throw new Exception("Noch keine BSO-Version auf dem Server");
|
||||
byte[] zip = await Api.Download(latest, p => Progress("mod", p, $"BSO {latest.version} wird geladen …"));
|
||||
Progress("mod", 1, "Installieren …");
|
||||
Game.InstallMod(folder, zip);
|
||||
return new { version = latest.version };
|
||||
}
|
||||
|
||||
async Task<object> Launch()
|
||||
{
|
||||
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen");
|
||||
if (!Game.DotnetInstalled()) throw new Exception("Fuer BSO fehlt die .NET 10 Runtime (x64) - unter \"Status\" herunterladen");
|
||||
if (Game.IsRunning()) throw new Exception("Bus-Simulator 2012 laeuft schon");
|
||||
|
||||
// Vorher aktualisieren (oder installieren, wenn BSO noch fehlt)
|
||||
string? installed = Game.ModVersion(folder);
|
||||
if (installed == null || settings.AutoUpdateMod)
|
||||
{
|
||||
var latest = await Api.Latest("mod");
|
||||
if (latest != null && (installed == null || SelfUpdate.IsNewer(latest.version, installed))) await InstallMod();
|
||||
else if (installed == null) throw new Exception("BSO ist nicht installiert und der BSO-Server ist nicht erreichbar");
|
||||
}
|
||||
|
||||
Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect);
|
||||
Game.Launch();
|
||||
LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})");
|
||||
if (!settings.KeepOpen)
|
||||
{
|
||||
await Task.Delay(2500);
|
||||
BeginInvoke(Close);
|
||||
}
|
||||
return new { started = true };
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
namespace BSO.Launcher;
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static int Main(string[] args)
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
// Deinstallieren (aus "Apps & Features")
|
||||
if (args.Contains("--uninstall"))
|
||||
{
|
||||
Installer.Uninstall(args.Contains("--quiet"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Wie Discord: die heruntergeladene .exe installiert sich selbst und startet dann die installierte Kopie.
|
||||
// Ist schon dieselbe oder eine neuere Version installiert, wird einfach diese geoeffnet. --portable = ohne Installation.
|
||||
if (!Installer.RunningInstalled && !args.Contains("--portable"))
|
||||
{
|
||||
if (Installer.NeedsInstall()) Application.Run(new InstallForm());
|
||||
else Installer.StartInstalled();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Nur ein Launcher gleichzeitig
|
||||
using var single = new Mutex(true, "BSO-Launcher-Single", out bool first);
|
||||
// Nach einem Selbst-Update: die alte Version beendet sich gerade - bis zu 20 s auf sie warten
|
||||
if (!first && args.Contains(SelfUpdate.AfterUpdateArg))
|
||||
{
|
||||
try { first = single.WaitOne(TimeSpan.FromSeconds(20)); }
|
||||
catch (AbandonedMutexException) { first = true; } // alte Version wurde hart beendet - Sperre gehoert jetzt uns
|
||||
}
|
||||
if (!first)
|
||||
{
|
||||
MessageBox.Show("Der BSO Launcher laeuft schon.", Installer.AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return 0;
|
||||
}
|
||||
SelfUpdate.CleanupOldVersion();
|
||||
if (Installer.RunningInstalled) Installer.Register(); // Version in "Apps & Features" nach einem Selbst-Update
|
||||
Application.Run(new MainForm());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# BSO Launcher
|
||||
|
||||
Launcher für **BSO – Bus Simulator Online** (Multiplayer-Mod für Bus-Simulator 2012), aufgebaut wie der MSCO Launcher:
|
||||
WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebettet.
|
||||
|
||||
- Mod: [BSO-Mod](https://git.mbm.wtf/MrMaho/BSO-Mod)
|
||||
- Dedizierter Server: [BSO-Server](https://git.mbm.wtf/MrMaho/BSO-Server)
|
||||
- Website, Konten und Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website)
|
||||
|
||||
## Features
|
||||
|
||||
- **Installation wie Discord:** Die heruntergeladene `BSO-Launcher.exe` installiert sich selbst – ohne Admin-Rechte nach
|
||||
`%LocalAppData%\Programs\BSO Launcher`, mit Verknüpfungen auf dem Desktop und im Startmenü und einem Eintrag unter
|
||||
*Einstellungen → Apps*. `--portable` startet ohne Installation.
|
||||
- **BSO-Konto:** Registrieren und Anmelden bei der BSO-Website. Die Anmeldung bleibt 30 Tage (Token DPAPI-verschlüsselt).
|
||||
Der Kontoname ist der Name im Spiel.
|
||||
- **Auto-Update:** neueste Mod-Version vor jedem Start (abschaltbar), Selbst-Update des Launchers mit einem Klick.
|
||||
Downloads sind fortsetzbar, SHA-256-geprüft und müssen mit dem **BSO-Release-Schlüssel signiert** sein.
|
||||
- **Prüft alles:** Spielordner (alle Steam-Bibliotheken), BSO-Version, .NET 10 Runtime (braucht die Mod).
|
||||
- **SPIELEN:** schreibt Name und Server in `BSO.json`, startet Bus-Simulator 2012 über Steam und verbindet automatisch.
|
||||
- Neuigkeiten (Changelogs), Einstellungen, Deinstallieren.
|
||||
|
||||
## Bauen und veröffentlichen
|
||||
|
||||
```powershell
|
||||
dotnet publish -c Release -o publish # eine .exe, ohne vorinstalliertes .NET
|
||||
.\release.ps1 -Product launcher -Version 0.1.0 -File publish\BSO-Launcher.exe -Changelog "..."
|
||||
```
|
||||
|
||||
Zum Testen gegen eine lokale Website: `$env:BSO_LAUNCHER_API = "http://localhost:5080"`, dann
|
||||
`publish\BSO-Launcher.exe --portable`.
|
||||
|
||||
## Release-Schlüssel
|
||||
|
||||
`new-release-key.ps1` legt einmalig den Schlüssel an (RSA 3072, privater Teil DPAPI-verschlüsselt unter
|
||||
`%APPDATA%\BSO\release-key.dpapi`) und trägt den öffentlichen Teil in `Services\ReleaseSignature.cs` ein.
|
||||
`release.ps1` (in jedem BSO-Repo) signiert damit `BSO-RELEASE|produkt|version|sha256` und lädt hoch – braucht
|
||||
`BSO_RELEASE_TOKEN` (= `RELEASE_TOKEN` der Website) und optional `BSO_MASTER_URL`.
|
||||
|
||||
**Den Schlüssel sichern:** Mit einem neuen Schlüssel erkennen bereits verteilte Launcher keine Updates mehr.
|
||||
|
||||
## Daten
|
||||
|
||||
| Was | Wo |
|
||||
|---|---|
|
||||
| Einstellungen, Anmeldung | `%LocalAppData%\BSO Launcher\settings.json` |
|
||||
| Protokoll | `%LocalAppData%\BSO Launcher\launcher.log` |
|
||||
| Mod | `<Spielordner>\Bin_High_Win64\dinput8.dll` und `BSO\` |
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>BSO Master: Konto und Releases (wie Api.cs beim MSCO Launcher).</summary>
|
||||
public static class Api
|
||||
{
|
||||
public const string BaseUrl = "https://bso.mbm.wtf";
|
||||
/// <summary>Zum Testen umstellbar: BSO_LAUNCHER_API=http://localhost:5080</summary>
|
||||
public static string Base => (Environment.GetEnvironmentVariable("BSO_LAUNCHER_API") ?? BaseUrl).TrimEnd('/');
|
||||
|
||||
public static readonly HttpClient Http = CreateHttp();
|
||||
|
||||
static HttpClient CreateHttp()
|
||||
{
|
||||
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("BSO-Launcher/" + SelfUpdate.CurrentVersion);
|
||||
return http;
|
||||
}
|
||||
|
||||
public sealed record Release(string product, string version, string sha256, long size, string changelog, string date, string file, string url,
|
||||
string? signature = null);
|
||||
|
||||
/// <summary>JSON-Antwort oder Fehlertext des Servers.</summary>
|
||||
public sealed class Result
|
||||
{
|
||||
public bool Ok;
|
||||
public int Status;
|
||||
public JsonElement Json;
|
||||
public string? Error;
|
||||
}
|
||||
|
||||
static async Task<Result> Send(HttpMethod method, string path, object? body, string? token)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, Base + path);
|
||||
if (body != null) req.Content = JsonContent.Create(body);
|
||||
if (!string.IsNullOrEmpty(token)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
try
|
||||
{
|
||||
using var res = await Http.SendAsync(req);
|
||||
string text = await res.Content.ReadAsStringAsync();
|
||||
var result = new Result { Ok = res.IsSuccessStatusCode, Status = (int)res.StatusCode };
|
||||
try { result.Json = JsonDocument.Parse(text).RootElement.Clone(); } catch { }
|
||||
if (!result.Ok)
|
||||
result.Error = result.Json.ValueKind == JsonValueKind.Object && result.Json.TryGetProperty("error", out var e)
|
||||
? e.GetString() : $"HTTP {(int)res.StatusCode}";
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new Result { Ok = false, Status = 0, Error = "BSO-Server nicht erreichbar (" + e.Message + ")" };
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<Result> Login(string username, string password) =>
|
||||
Send(HttpMethod.Post, "/api/auth/login", new { username, password }, null);
|
||||
|
||||
public static Task<Result> Register(string username, string password, string? email) =>
|
||||
Send(HttpMethod.Post, "/api/auth/register", new { username, password, email }, null);
|
||||
|
||||
public static Task<Result> Me(string token) => Send(HttpMethod.Get, "/api/auth/me", null, token);
|
||||
|
||||
public static Task<Result> Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token);
|
||||
|
||||
public static async Task<Release?> Latest(string product)
|
||||
{
|
||||
var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null);
|
||||
if (!r.Ok) return null;
|
||||
try { return r.Json.Deserialize<Release>(); } catch { return null; }
|
||||
}
|
||||
|
||||
public static Task<Result> Releases() => Send(HttpMethod.Get, "/api/releases", null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Mod/Launcher laden: Signatur des Releases und SHA-256 pruefen (nur echte, vom BSO-Release-Schluessel signierte
|
||||
/// Dateien werden installiert). Fortschritt 0..1.
|
||||
/// </summary>
|
||||
public static async Task<byte[]> Download(Release release, Action<double> progress)
|
||||
{
|
||||
if (!ReleaseSignature.Verify(release.product, release.version, release.sha256, release.signature))
|
||||
throw new Exception($"{release.product} {release.version} ist nicht von BSO signiert - nicht installiert");
|
||||
return await DownloadVerified(release.url, release.sha256, release.size, progress);
|
||||
}
|
||||
|
||||
/// <summary>Teil-Downloads (fortsetzbar) - liegen beim Launcher, benannt nach der Pruefsumme der Datei.</summary>
|
||||
static string PartFolder => Path.Combine(Settings.Folder, "downloads");
|
||||
|
||||
/// <summary>
|
||||
/// Datei laden und gegen die Pruefsumme pruefen. Fortsetzbar: geladen wird in eine Teildatei (<sha256>.part). Bricht
|
||||
/// das Netz ab, wird gewartet und ab der letzten Stelle weitergeladen (HTTP Range).
|
||||
/// </summary>
|
||||
public static async Task<byte[]> DownloadVerified(string url, string sha256, long size, Action<double> progress)
|
||||
{
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(sha256 ?? "", "^[0-9a-fA-F]{64}$")) throw new Exception("Ungueltige Pruefsumme");
|
||||
Directory.CreateDirectory(PartFolder);
|
||||
string part = Path.Combine(PartFolder, sha256!.ToLowerInvariant() + ".part");
|
||||
int failures = 0;
|
||||
while (true)
|
||||
{
|
||||
long have = File.Exists(part) ? new FileInfo(part).Length : 0;
|
||||
if (size > 0 && have > size) { File.Delete(part); have = 0; }
|
||||
try
|
||||
{
|
||||
if (size <= 0 || have < size) await DownloadPart(url, part, have, size, progress);
|
||||
byte[] data = await File.ReadAllBytesAsync(part);
|
||||
string sha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(data)).ToLowerInvariant();
|
||||
if (!string.Equals(sha, sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(part); // kaputt - naechster Versuch von vorn
|
||||
throw new InvalidDataException("Der Download ist beschaedigt (Pruefsumme passt nicht) - bitte nochmal versuchen");
|
||||
}
|
||||
try { File.Delete(part); } catch { }
|
||||
return data;
|
||||
}
|
||||
catch (Exception e) when (e is HttpRequestException or IOException or TaskCanceledException or OperationCanceledException
|
||||
&& e is not InvalidDataException)
|
||||
{
|
||||
// Netz weg / Verbindung abgebrochen: kurz warten, dann ab der letzten Stelle weiter
|
||||
long now = File.Exists(part) ? new FileInfo(part).Length : 0;
|
||||
failures = now > have ? 1 : failures + 1;
|
||||
if (failures > 8) throw new Exception("Download abgebrochen (keine Verbindung) - nochmal versuchen, er macht dort weiter");
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 2 * failures)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async Task DownloadPart(string url, string part, long have, long size, Action<double> progress)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (have > 0) req.Headers.Range = new RangeHeaderValue(have, null);
|
||||
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
|
||||
if (res.StatusCode == System.Net.HttpStatusCode.RequestedRangeNotSatisfiable)
|
||||
{
|
||||
File.Delete(part); // Teildatei passt nicht zur Datei auf dem Server - von vorn
|
||||
throw new IOException("Range not satisfiable");
|
||||
}
|
||||
res.EnsureSuccessStatusCode();
|
||||
bool resumed = have > 0 && res.StatusCode == System.Net.HttpStatusCode.PartialContent;
|
||||
if (!resumed) have = 0; // Server liefert die ganze Datei
|
||||
long total = size > 0 ? size : have + (res.Content.Headers.ContentLength ?? 0);
|
||||
await using var input = await res.Content.ReadAsStreamAsync();
|
||||
await using var output = new FileStream(part, resumed ? FileMode.Append : FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
var chunk = new byte[81920];
|
||||
long done = have;
|
||||
if (total > 0) progress(Math.Min(1.0, done / (double)total));
|
||||
while (true)
|
||||
{
|
||||
// Haengt die Verbindung (keine Daten mehr), nach 30 s abbrechen und neu ansetzen
|
||||
using var stall = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
int read = await input.ReadAsync(chunk, stall.Token);
|
||||
if (read <= 0) break;
|
||||
await output.WriteAsync(chunk.AsMemory(0, read));
|
||||
done += read;
|
||||
if (total > 0) progress(Math.Min(1.0, done / (double)total));
|
||||
}
|
||||
await output.FlushAsync();
|
||||
if (size > 0 && done < size) throw new IOException("Verbindung zu frueh beendet");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Bus-Simulator 2012 finden (alle Steam-Bibliotheken), BSO in den Spielordner installieren (dinput8.dll + BSO\),
|
||||
/// BSO.json fuer die Mod schreiben (Name = Kontoname, Server) und das Spiel ueber Steam starten.
|
||||
/// </summary>
|
||||
public static class Game
|
||||
{
|
||||
public const string SteamAppId = "253770";
|
||||
const string GameFolderName = "Bus-Simulator 2012";
|
||||
const string BinFolder = "Bin_High_Win64"; // BSO laeuft nur in der 64-Bit-Version
|
||||
const string ExeName = "BusSimulator2012.exe";
|
||||
|
||||
/// <summary>Spielordner (der mit Bin_High_Win64) oder null.</summary>
|
||||
public static string? FindFolder(Settings settings)
|
||||
{
|
||||
if (IsGameFolder(settings.GameFolder)) return settings.GameFolder;
|
||||
foreach (string library in SteamLibraries())
|
||||
{
|
||||
string folder = Path.Combine(library, "steamapps", "common", GameFolderName);
|
||||
if (IsGameFolder(folder)) return folder;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsGameFolder(string? folder) =>
|
||||
!string.IsNullOrEmpty(folder) && File.Exists(Path.Combine(folder, BinFolder, ExeName));
|
||||
|
||||
public static string Bin(string folder) => Path.Combine(folder, BinFolder);
|
||||
|
||||
static IEnumerable<string> SteamLibraries()
|
||||
{
|
||||
string? steam = null;
|
||||
try { steam = Registry.CurrentUser.OpenSubKey(@"Software\Valve\Steam")?.GetValue("SteamPath") as string; } catch { }
|
||||
steam ??= Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Steam");
|
||||
steam = steam.Replace('/', '\\');
|
||||
yield return steam;
|
||||
string vdf = Path.Combine(steam, "steamapps", "libraryfolders.vdf");
|
||||
if (!File.Exists(vdf)) yield break;
|
||||
foreach (Match m in Regex.Matches(File.ReadAllText(vdf), "\"path\"\\s+\"([^\"]+)\""))
|
||||
yield return m.Groups[1].Value.Replace(@"\\", @"\");
|
||||
}
|
||||
|
||||
/// <summary>Installierte BSO-Version (aus BSO.dll) oder null.</summary>
|
||||
public static string? ModVersion(string folder)
|
||||
{
|
||||
string dll = Path.Combine(Bin(folder), "BSO", "BSO.dll");
|
||||
if (!File.Exists(dll) || !File.Exists(Path.Combine(Bin(folder), "dinput8.dll"))) return null;
|
||||
return FileVersionInfo.GetVersionInfo(dll).ProductVersion?.Split('+')[0];
|
||||
}
|
||||
|
||||
/// <summary>Die Mod braucht die .NET 10 Runtime (x64) - der Launcher selbst bringt seine mit, die Mod nicht.</summary>
|
||||
public static bool DotnetInstalled()
|
||||
{
|
||||
string shared = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "shared", "Microsoft.NETCore.App");
|
||||
return Directory.Exists(shared) && Directory.GetDirectories(shared, "10.*").Length > 0;
|
||||
}
|
||||
|
||||
public const string DotnetDownload = "https://dotnet.microsoft.com/download/dotnet/10.0";
|
||||
|
||||
public static bool IsRunning() => Process.GetProcessesByName(Path.GetFileNameWithoutExtension(ExeName)).Length > 0;
|
||||
|
||||
/// <summary>Mod-Paket (BSO-Mod.zip: dinput8.dll, BSO\*) in den Spielordner entpacken. BSO.json bleibt.</summary>
|
||||
public static void InstallMod(string folder, byte[] zip)
|
||||
{
|
||||
if (IsRunning()) throw new Exception("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Mod-Dateien gesperrt");
|
||||
string bin = Bin(folder);
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
foreach (ZipArchiveEntry entry in archive.Entries)
|
||||
{
|
||||
if (entry.FullName.EndsWith('/')) continue;
|
||||
string target = Path.GetFullPath(Path.Combine(bin, entry.FullName));
|
||||
if (!target.StartsWith(Path.GetFullPath(bin) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new Exception("Ungueltiger Pfad im Mod-Paket: " + entry.FullName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
entry.ExtractToFile(target, true);
|
||||
}
|
||||
LauncherLog.Info("BSO installiert nach " + bin);
|
||||
}
|
||||
|
||||
/// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden.</summary>
|
||||
public static void WriteModConfig(string folder, string? name, string server, bool autoConnect)
|
||||
{
|
||||
string file = Path.Combine(Bin(folder), "BSO", "BSO.json");
|
||||
JsonObject json;
|
||||
try { json = File.Exists(file) ? JsonNode.Parse(File.ReadAllText(file)) as JsonObject ?? new JsonObject() : new JsonObject(); }
|
||||
catch { json = new JsonObject(); }
|
||||
if (!string.IsNullOrEmpty(name)) json["Name"] = name;
|
||||
json["Server"] = server;
|
||||
json["AutoConnect"] = autoConnect;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
|
||||
File.WriteAllText(file, json.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
/// <summary>Ueber Steam starten (Overlay, Steam-Anmeldung des Spiels).</summary>
|
||||
public static void Launch() =>
|
||||
Process.Start(new ProcessStartInfo("steam://rungameid/" + SteamAppId) { UseShellExecute = true });
|
||||
|
||||
/// <summary>BSO aus dem Spielordner entfernen (Deinstallieren). Gibt Probleme als Text zurueck.</summary>
|
||||
public static List<string> RemoveBso(Settings settings)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
string? folder = FindFolder(settings);
|
||||
if (folder == null) return problems;
|
||||
string bin = Bin(folder);
|
||||
try { File.Delete(Path.Combine(bin, "dinput8.dll")); } catch (Exception e) { problems.Add("dinput8.dll: " + e.Message); }
|
||||
try { if (Directory.Exists(Path.Combine(bin, "BSO"))) Directory.Delete(Path.Combine(bin, "BSO"), true); }
|
||||
catch (Exception e) { problems.Add("BSO-Ordner: " + e.Message); }
|
||||
return problems;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Installation wie bei Discord (wie beim MSCO Launcher): die heruntergeladene .exe installiert sich selbst ohne
|
||||
/// Admin-Rechte nach %LocalAppData%\Programs\BSO Launcher, legt Verknuepfungen (Desktop, Startmenue) an, traegt sich unter
|
||||
/// "Apps & Features" ein (Deinstallieren) und startet dann den installierten Launcher.
|
||||
/// </summary>
|
||||
public static class Installer
|
||||
{
|
||||
public const string AppName = "BSO Launcher";
|
||||
const string ExeName = "BSO-Launcher.exe";
|
||||
const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\BSO-Launcher";
|
||||
|
||||
public static string InstallDir =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName);
|
||||
public static string InstalledExe => Path.Combine(InstallDir, ExeName);
|
||||
static string Self => Environment.ProcessPath ?? Application.ExecutablePath;
|
||||
|
||||
static string StartMenuShortcut =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName + ".lnk");
|
||||
static string DesktopShortcut =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), AppName + ".lnk");
|
||||
|
||||
/// <summary>Laeuft gerade die installierte Kopie?</summary>
|
||||
public static bool RunningInstalled =>
|
||||
string.Equals(Path.GetFullPath(Self), Path.GetFullPath(InstalledExe), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static string? InstalledVersion()
|
||||
{
|
||||
if (!File.Exists(InstalledExe)) return null;
|
||||
return FileVersionInfo.GetVersionInfo(InstalledExe).ProductVersion?.Split('+')[0];
|
||||
}
|
||||
|
||||
/// <summary>Muss installiert (oder aktualisiert) werden? false = installierte Version ist gleich/neuer.</summary>
|
||||
public static bool NeedsInstall()
|
||||
{
|
||||
string? installed = InstalledVersion();
|
||||
return installed == null || SelfUpdate.IsNewer(SelfUpdate.CurrentVersion, installed);
|
||||
}
|
||||
|
||||
/// <summary>Installieren. progress(0..1, Text).</summary>
|
||||
public static void Install(Action<double, string> progress)
|
||||
{
|
||||
progress(0.1, "Laufenden Launcher schliessen …");
|
||||
foreach (var p in Process.GetProcessesByName("BSO-Launcher"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (p.Id == Environment.ProcessId) continue;
|
||||
if (!string.Equals(p.MainModule?.FileName, InstalledExe, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
p.CloseMainWindow();
|
||||
if (!p.WaitForExit(3000)) p.Kill(true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
progress(0.3, "Dateien kopieren …");
|
||||
Directory.CreateDirectory(InstallDir);
|
||||
string tmp = InstalledExe + ".new";
|
||||
File.Copy(Self, tmp, true);
|
||||
File.Move(tmp, InstalledExe, true);
|
||||
|
||||
progress(0.6, "Verknuepfungen anlegen …");
|
||||
CreateShortcut(StartMenuShortcut);
|
||||
CreateShortcut(DesktopShortcut);
|
||||
|
||||
progress(0.8, "Registrieren …");
|
||||
Register();
|
||||
progress(1.0, "Fertig");
|
||||
}
|
||||
|
||||
/// <summary>Eintrag unter "Apps & Features" (bzw. dessen Version nach einem Selbst-Update).</summary>
|
||||
public static void Register()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.CreateSubKey(UninstallKey);
|
||||
key.SetValue("DisplayName", AppName);
|
||||
key.SetValue("DisplayVersion", SelfUpdate.CurrentVersion);
|
||||
key.SetValue("Publisher", "MBM.wtf");
|
||||
key.SetValue("DisplayIcon", InstalledExe + ",0");
|
||||
key.SetValue("InstallLocation", InstallDir);
|
||||
key.SetValue("UninstallString", $"\"{InstalledExe}\" --uninstall");
|
||||
key.SetValue("QuietUninstallString", $"\"{InstalledExe}\" --uninstall --quiet");
|
||||
key.SetValue("URLInfoAbout", Api.BaseUrl);
|
||||
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
|
||||
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||||
try { key.SetValue("EstimatedSize", (int)(new FileInfo(InstalledExe).Length / 1024), RegistryValueKind.DWord); } catch { }
|
||||
}
|
||||
catch { /* Eintrag ist nur Komfort */ }
|
||||
}
|
||||
|
||||
static void CreateShortcut(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Type? shellType = Type.GetTypeFromProgID("WScript.Shell");
|
||||
if (shellType == null) return;
|
||||
dynamic shell = Activator.CreateInstance(shellType)!;
|
||||
dynamic link = shell.CreateShortcut(path);
|
||||
link.TargetPath = InstalledExe;
|
||||
link.WorkingDirectory = InstallDir;
|
||||
link.IconLocation = InstalledExe + ",0";
|
||||
link.Description = "BSO - Bus Simulator Online";
|
||||
link.Save();
|
||||
}
|
||||
catch { /* ohne Verknuepfung geht es auch */ }
|
||||
}
|
||||
|
||||
public static void StartInstalled()
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(InstalledExe) { UseShellExecute = true, WorkingDirectory = InstallDir });
|
||||
}
|
||||
|
||||
/// <summary>Ordner/Dateien loeschen, sobald dieser Prozess beendet ist (seine .exe ist bis dahin gesperrt).</summary>
|
||||
static void RemoveAfterExit(params string[] paths)
|
||||
{
|
||||
static string Quote(string p) => "'" + p.Replace("'", "''") + "'";
|
||||
string list = string.Join(",", paths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(Quote));
|
||||
string script =
|
||||
$"Wait-Process -Id {Environment.ProcessId} -ErrorAction SilentlyContinue; " +
|
||||
$"foreach ($i in 1..30) {{ $left = $false; foreach ($p in @({list})) {{ " +
|
||||
"if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue; " +
|
||||
"if (Test-Path -LiteralPath $p) { $left = $true } } }; if (-not $left) { break }; Start-Sleep -Seconds 1 }";
|
||||
var psi = new ProcessStartInfo("powershell.exe") { CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden };
|
||||
foreach (string a in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script })
|
||||
psi.ArgumentList.Add(a);
|
||||
try { Process.Start(psi); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>Deinstallieren (aus "Apps & Features" oder den Einstellungen). false = abgebrochen.</summary>
|
||||
public static bool Uninstall(bool quiet)
|
||||
{
|
||||
if (!quiet)
|
||||
{
|
||||
var answer = MessageBox.Show(
|
||||
"BSO komplett deinstallieren?\n\nDas entfernt den Launcher, BSO aus dem Bus-Simulator-2012-Ordner (dinput8.dll, BSO-Ordner) " +
|
||||
"und deine Anmeldung im Launcher.",
|
||||
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
|
||||
if (answer != DialogResult.Yes) return false;
|
||||
while (Game.IsRunning())
|
||||
{
|
||||
if (MessageBox.Show("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Dateien in Benutzung.", AppName,
|
||||
MessageBoxButtons.RetryCancel, MessageBoxIcon.Warning) != DialogResult.Retry) return false;
|
||||
}
|
||||
}
|
||||
string dir = InstallDir;
|
||||
foreach (var p in Process.GetProcessesByName("BSO-Launcher"))
|
||||
try { if (p.Id != Environment.ProcessId) p.Kill(true); } catch { }
|
||||
|
||||
List<string> problems = Game.RemoveBso(Settings.Load());
|
||||
try { File.Delete(StartMenuShortcut); } catch { }
|
||||
try { File.Delete(DesktopShortcut); } catch { }
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, false); } catch { }
|
||||
// Die laufende .exe laesst sich nicht selbst loeschen: aufraeumen, sobald dieser Prozess beendet ist
|
||||
RemoveAfterExit(dir, Settings.Folder);
|
||||
if (!quiet)
|
||||
MessageBox.Show(problems.Count == 0
|
||||
? "BSO wurde komplett deinstalliert."
|
||||
: "BSO wurde deinstalliert. Einiges liess sich nicht entfernen:\n\n" + string.Join("\n", problems),
|
||||
AppName, MessageBoxButtons.OK, problems.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static Image? Logo()
|
||||
{
|
||||
var s = Assembly.GetExecutingAssembly().GetManifestResourceStream("ui/logo.png");
|
||||
return s == null ? null : Image.FromStream(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kleines Installationsfenster (wie der Discord-Installer): Logo, Status, Fortschrittsbalken.</summary>
|
||||
public sealed class InstallForm : Form
|
||||
{
|
||||
readonly Label status = new();
|
||||
double progress;
|
||||
string? error;
|
||||
|
||||
public InstallForm()
|
||||
{
|
||||
FormBorderStyle = FormBorderStyle.None;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
ClientSize = new Size(420, 260);
|
||||
BackColor = Color.FromArgb(0x15, 0x19, 0x22);
|
||||
ShowInTaskbar = true;
|
||||
Text = "BSO Launcher wird installiert";
|
||||
DoubleBuffered = true;
|
||||
try { Icon = Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? Application.ExecutablePath); } catch { }
|
||||
|
||||
status.ForeColor = Color.FromArgb(0x8b, 0x95, 0xa5);
|
||||
status.Font = new Font("Segoe UI", 9.5f);
|
||||
status.TextAlign = ContentAlignment.MiddleCenter;
|
||||
status.SetBounds(20, 178, 380, 24);
|
||||
status.Text = "Vorbereiten …";
|
||||
Controls.Add(status);
|
||||
Shown += async (_, _) => await Run();
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
using (var border = new Pen(Color.FromArgb(0x26, 0x2c, 0x38))) g.DrawRectangle(border, 0, 0, Width - 1, Height - 1);
|
||||
var logo = Installer.Logo();
|
||||
if (logo != null) g.DrawImage(logo, (Width - 72) / 2, 34, 72, 72);
|
||||
using var title = new Font("Segoe UI", 15f, FontStyle.Bold);
|
||||
TextRenderer.DrawText(g, "BSO Launcher", title, new Rectangle(0, 116, Width, 34), Color.White, TextFormatFlags.HorizontalCenter);
|
||||
using var sub = new Font("Segoe UI", 9f);
|
||||
TextRenderer.DrawText(g, "Bus Simulator Online", sub, new Rectangle(0, 148, Width, 22), Color.FromArgb(0x8b, 0x95, 0xa5), TextFormatFlags.HorizontalCenter);
|
||||
// Fortschrittsbalken
|
||||
var bar = new Rectangle(40, 214, Width - 80, 8);
|
||||
using (var back = new SolidBrush(Color.FromArgb(0x23, 0x2a, 0x36))) g.FillRectangle(back, bar);
|
||||
if (progress > 0)
|
||||
{
|
||||
var fill = new Rectangle(bar.X, bar.Y, (int)(bar.Width * progress), bar.Height);
|
||||
using var brush = new System.Drawing.Drawing2D.LinearGradientBrush(bar, Color.FromArgb(0x1f, 0x6f, 0xe5), Color.FromArgb(0x5e, 0xe4, 0xff), 0f);
|
||||
g.FillRectangle(error != null ? Brushes.IndianRed : brush, fill);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Run(() => Installer.Install((p, text) => BeginInvoke(() => { progress = p; status.Text = text; Invalidate(); })));
|
||||
await Task.Delay(400);
|
||||
Installer.StartInstalled();
|
||||
Close();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
error = e.Message;
|
||||
status.ForeColor = Color.FromArgb(0xff, 0x8a, 0x8d);
|
||||
status.Text = "Installation fehlgeschlagen: " + e.Message;
|
||||
Invalidate();
|
||||
await Task.Delay(6000);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>Protokoll des Launchers: %LocalAppData%\BSO Launcher\launcher.log (wird beim Start ab 1 MB neu begonnen).</summary>
|
||||
public static class LauncherLog
|
||||
{
|
||||
static readonly object Gate = new();
|
||||
public static string FilePath => Path.Combine(Settings.Folder, "launcher.log");
|
||||
|
||||
public static void Info(string line) => Write("INFO ", line);
|
||||
public static void Error(string line) => Write("ERROR", line);
|
||||
|
||||
static void Write(string level, string line)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Settings.Folder);
|
||||
var info = new FileInfo(FilePath);
|
||||
if (info.Exists && info.Length > 1024 * 1024) info.Delete();
|
||||
File.AppendAllText(FilePath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {level} {line}{Environment.NewLine}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Prueft die Signatur von Mod-, Server- und Launcher-Releases (wie bei MSCO). tools\release.ps1 signiert
|
||||
/// "BSO-RELEASE|produkt|version|sha256" mit dem privaten Release-Schluessel (liegt nur beim Entwickler, DPAPI-verschluesselt);
|
||||
/// hier ist nur der oeffentliche Schluessel eingebaut (eingetragen von tools\new-release-key.ps1). Ohne gueltige Signatur
|
||||
/// wird nichts installiert - selbst wer den Master oder den Release-Token uebernimmt, kann keine fremde Datei verteilen.
|
||||
/// </summary>
|
||||
public static class ReleaseSignature
|
||||
{
|
||||
const string PublicKey = "<RSAKeyValue><Modulus>mWnJlmK+ZHcbVdN+AEvB2pU8cYLmIAygylltXK7/Gb6Qs0vDlOK9vrXy8+Yx0//WkWrV2pgvuPErlnmwCdHp5RTEr4whXqfGXs2KphQD7wI7kSAA9ETUULgztv4oaW8ndmaAJvBdnLVF3IB0snH51abd0Y+JCnQ/64J8CP0zECdoLFebmGP6wYV75oZrNh9v9EqaQ3qH56SnwUH8dFoR7nkoi6M2o+S1/pL4hTpBGMWmm1DJXsck4HJTlblEDPkT9ksgJf9oj3aZrLGwne+ZOLQ+DJF1SDmFT75bWPUvW8WP4TJnG85epn7JwsKLFNy2wb2UtJLAdF+KCIgefnQU/qXvSYRg/LIWxX9FVysQW2re1dcm+kI+NLddA88i6xpi7Ziste0NzyKD6SnaEP8fNVuoobpgW+S8Gfq7pemxA9/EibqXdyCbsT+IHF5SaEHU6EbC50bRF2ssoMevf2pPv/6omgHaT1BUiQPEetMEx0HOzcnMlmVNk6hNkiXBngop</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>";
|
||||
|
||||
/// <summary>Text, der signiert wird: passt nur zu genau dieser Datei, diesem Produkt und dieser Version.</summary>
|
||||
public static string Message(string product, string version, string sha256) =>
|
||||
$"BSO-RELEASE|{product}|{version}|{sha256.ToLowerInvariant()}";
|
||||
|
||||
public static bool Verify(string product, string version, string sha256, string? signature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature)) return false;
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.FromXmlString(PublicKey);
|
||||
return rsa.VerifyData(Encoding.UTF8.GetBytes(Message(product, version, sha256)), Convert.FromBase64String(signature),
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Der Launcher aktualisiert sich selbst (wie bei MSCO): neue .exe vom Master laden (Signatur + SHA-256 geprueft), die
|
||||
/// laufende Datei in "*.old" umbenennen (geht unter Windows auch waehrend sie laeuft), die neue an ihre Stelle legen und
|
||||
/// neu starten.
|
||||
/// </summary>
|
||||
public static class SelfUpdate
|
||||
{
|
||||
public static string CurrentVersion =>
|
||||
(Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.0.0")
|
||||
.Split('+')[0];
|
||||
|
||||
static string ExePath => Environment.ProcessPath ?? Application.ExecutablePath;
|
||||
|
||||
public static bool IsNewer(string candidate, string than) =>
|
||||
Version.TryParse(candidate, out var a) && Version.TryParse(than, out var b) && a > b;
|
||||
|
||||
/// <summary>Nach einem Update: die alte Datei aufraeumen.</summary>
|
||||
public static void CleanupOldVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
string old = ExePath + ".old";
|
||||
if (File.Exists(old)) File.Delete(old);
|
||||
}
|
||||
catch { /* laeuft evtl. noch kurz */ }
|
||||
}
|
||||
|
||||
/// <summary>Startoption der neuen Version nach einem Selbst-Update: auf das Ende der alten warten.</summary>
|
||||
public const string AfterUpdateArg = "--after-update";
|
||||
|
||||
public static async Task<string> Install(Api.Release release, Action<double> progress)
|
||||
{
|
||||
byte[] data = await Api.Download(release, progress);
|
||||
string exe = ExePath, fresh = exe + ".new", old = exe + ".old";
|
||||
await File.WriteAllBytesAsync(fresh, data);
|
||||
if (File.Exists(old)) File.Delete(old);
|
||||
File.Move(exe, old);
|
||||
File.Move(fresh, exe);
|
||||
// Die neue Version wartet, bis diese hier beendet ist (sonst meldet sie "laeuft schon" und beendet sich)
|
||||
var start = new ProcessStartInfo(exe) { UseShellExecute = true };
|
||||
start.ArgumentList.Add(AfterUpdateArg);
|
||||
if (!Installer.RunningInstalled) start.ArgumentList.Add("--portable");
|
||||
Process.Start(start);
|
||||
return release.version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen und Anmeldung in %LocalAppData%\BSO Launcher\settings.json. Der Token ist mit DPAPI verschluesselt
|
||||
/// (nur dieser Windows-Benutzer auf diesem PC kann ihn lesen).
|
||||
/// </summary>
|
||||
public sealed class Settings
|
||||
{
|
||||
public static string Folder => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BSO Launcher");
|
||||
static string FilePath => Path.Combine(Folder, "settings.json");
|
||||
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
||||
|
||||
public string? Username { get; set; }
|
||||
public string? ProtectedToken { get; set; }
|
||||
/// <summary>Spielordner von Hand gewaehlt (sonst automatisch aus den Steam-Bibliotheken).</summary>
|
||||
public string? GameFolder { get; set; }
|
||||
public string Server { get; set; } = "127.0.0.1:7777";
|
||||
public bool AutoConnect { get; set; } = true;
|
||||
public bool KeepOpen { get; set; }
|
||||
public bool AutoUpdateMod { get; set; } = true;
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public string? Token
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(ProtectedToken)) return null;
|
||||
try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(ProtectedToken), null, DataProtectionScope.CurrentUser)); }
|
||||
catch { return null; }
|
||||
}
|
||||
set => ProtectedToken = value == null ? null
|
||||
: Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(value), null, DataProtectionScope.CurrentUser));
|
||||
}
|
||||
|
||||
public static Settings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath)) return JsonSerializer.Deserialize<Settings>(File.ReadAllText(FilePath)) ?? new Settings();
|
||||
}
|
||||
catch (Exception e) { LauncherLog.Error("settings.json nicht lesbar: " + e.Message); }
|
||||
return new Settings();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
Directory.CreateDirectory(Folder);
|
||||
string tmp = FilePath + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(this, Options));
|
||||
File.Move(tmp, FilePath, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Legt den Release-Schluessel an (einmalig, wie bei MSCO): RSA 3072, der private Teil DPAPI-verschluesselt unter
|
||||
# %APPDATA%\BSO\release-key.dpapi (nur dieser Windows-Benutzer kann ihn lesen). Den oeffentlichen Teil traegt das Skript in
|
||||
# Services\ReleaseSignature.cs ein - danach installiert der Launcher nur noch damit signierte Releases.
|
||||
# ACHTUNG: Neuer Schluessel = alte Launcher erkennen neue Releases nicht mehr. Den Schluessel also sichern, nicht neu anlegen.
|
||||
param([switch]$Force)
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Security
|
||||
$keyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi"
|
||||
if ((Test-Path $keyFile) -and -not $Force) { throw "Schluessel gibt es schon: $keyFile (mit -Force ueberschreiben - siehe ACHTUNG oben)" }
|
||||
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create(3072)
|
||||
$private = [Text.Encoding]::UTF8.GetBytes($rsa.ToXmlString($true))
|
||||
$protected = [System.Security.Cryptography.ProtectedData]::Protect($private, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
||||
New-Item -ItemType Directory -Force (Split-Path $keyFile) | Out-Null
|
||||
[IO.File]::WriteAllBytes($keyFile, $protected)
|
||||
|
||||
$public = $rsa.ToXmlString($false)
|
||||
$code = Join-Path $PSScriptRoot "Services\ReleaseSignature.cs"
|
||||
$text = [IO.File]::ReadAllText($code)
|
||||
$text = [regex]::Replace($text, '"<RSAKeyValue>.*?</RSAKeyValue>"', '"' + $public + '"')
|
||||
[IO.File]::WriteAllText($code, $text)
|
||||
Write-Host "Release-Schluessel angelegt: $keyFile"
|
||||
Write-Host "Oeffentlicher Schluessel eingetragen in $code"
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Signiert ein Release und laedt es auf den BSO Master (wie release.ps1 bei MSCO).
|
||||
# .\release.ps1 -Product mod|launcher|server -Version 0.2.0 -File dist\BSO-Mod.zip [-Changelog "..."]
|
||||
# Braucht: Release-Schluessel (BSO-Launcher\new-release-key.ps1), BSO_RELEASE_TOKEN (= RELEASE_TOKEN des Masters),
|
||||
# optional BSO_MASTER_URL (Standard https://bso.mbm.wtf).
|
||||
# Signiert wird "BSO-RELEASE|produkt|version|sha256" - der Launcher prueft das mit dem eingebauten oeffentlichen Schluessel.
|
||||
param(
|
||||
[Parameter(Mandatory)][ValidateSet("mod", "launcher", "server")][string]$Product,
|
||||
[Parameter(Mandatory)][string]$Version,
|
||||
[Parameter(Mandatory)][string]$File,
|
||||
[string]$Changelog = ""
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Security
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
|
||||
$master = ($env:BSO_MASTER_URL, "https://bso.mbm.wtf" | Where-Object { $_ } | Select-Object -First 1).TrimEnd('/')
|
||||
$token = $env:BSO_RELEASE_TOKEN
|
||||
if (-not $token) { Write-Host "BSO_RELEASE_TOKEN nicht gesetzt - nicht hochgeladen"; return }
|
||||
$keyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi"
|
||||
if (-not (Test-Path $keyFile)) { throw "Kein Release-Schluessel ($keyFile) - erst new-release-key.ps1 im BSO-Launcher-Repo" }
|
||||
|
||||
$data = [IO.File]::ReadAllBytes((Resolve-Path $File))
|
||||
$sha = ([System.Security.Cryptography.SHA256]::Create().ComputeHash($data) | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
$private = [System.Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $null,
|
||||
[System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private))
|
||||
$message = [Text.Encoding]::UTF8.GetBytes("BSO-RELEASE|$Product|$Version|$sha")
|
||||
$signature = [Convert]::ToBase64String($rsa.SignData($message, [System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1))
|
||||
|
||||
$http = New-Object System.Net.Http.HttpClient
|
||||
$http.Timeout = [TimeSpan]::FromMinutes(10)
|
||||
$http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token)
|
||||
$form = New-Object System.Net.Http.MultipartFormDataContent
|
||||
foreach ($pair in @(@("product", $Product), @("version", $Version), @("changelog", $Changelog), @("signature", $signature))) {
|
||||
$form.Add((New-Object System.Net.Http.StringContent($pair[1])), "`"$($pair[0])`"")
|
||||
}
|
||||
$content = New-Object System.Net.Http.ByteArrayContent(, $data)
|
||||
$form.Add($content, "`"file`"", "`"$(Split-Path $File -Leaf)`"")
|
||||
$res = $http.PostAsync("$master/api/releases/upload", $form).Result
|
||||
$body = $res.Content.ReadAsStringAsync().Result
|
||||
if ($res.StatusCode -eq [Net.HttpStatusCode]::Conflict) { Write-Host "$Product $Version gibt es schon auf $master"; return }
|
||||
if (-not $res.IsSuccessStatusCode) { throw "Hochladen fehlgeschlagen ($([int]$res.StatusCode)): $body" }
|
||||
Write-Host "$Product $Version hochgeladen nach $master (sha256 $sha)"
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
:root {
|
||||
--bg: #0b0d12;
|
||||
--panel: #151922;
|
||||
--panel-2: #1c212c;
|
||||
--sidebar: #080a0e;
|
||||
--border: #262c38;
|
||||
--text: #eaf0f7;
|
||||
--muted: #8b95a5;
|
||||
--accent: #2f9bff;
|
||||
--accent-2: #5ee4ff;
|
||||
--online: #3dd68c;
|
||||
--pending: #f2c230;
|
||||
--danger: #e5484d;
|
||||
--radius: 12px;
|
||||
--titlebar: 36px;
|
||||
--icons: "Segoe Fluent Icons", "Segoe MDL2 Assets";
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
input { user-select: text; }
|
||||
.hidden { display: none !important; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12.5px; }
|
||||
.center { text-align: center; }
|
||||
.error { color: #ff8a8d; margin: 0; }
|
||||
h1, h2, h3 { margin: 0; letter-spacing: -0.01em; }
|
||||
h1 { font-size: 26px; }
|
||||
h2 { font-size: 17px; }
|
||||
code, .mono { font-family: "Cascadia Mono", Consolas, monospace; font-size: 12.5px; }
|
||||
|
||||
/* ---------------------------------------------------------------- Titelleiste */
|
||||
.titlebar { position: fixed; inset: 0 0 auto 0; height: var(--titlebar); display: flex; z-index: 50;
|
||||
background: var(--sidebar); border-bottom: 1px solid #151922; }
|
||||
.titlebar-drag { flex: 1; display: flex; align-items: center; gap: 8px; padding: 0 12px; color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||
.titlebar-drag img { width: 18px; height: 18px; }
|
||||
.window-buttons { display: flex; }
|
||||
.window-buttons button { width: 46px; border: 0; background: transparent; color: #c4ccd8; font: 10px var(--icons); cursor: default; }
|
||||
.window-buttons button:hover { background: #1c212c; }
|
||||
.window-buttons .close:hover { background: #c42b1c; color: #fff; }
|
||||
|
||||
/* ---------------------------------------------------------------- Knoepfe & Felder */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--border);
|
||||
background: var(--panel-2); color: var(--text); font: inherit; font-weight: 700; padding: 9px 16px; border-radius: 10px;
|
||||
cursor: pointer; transition: background .12s, border-color .12s, transform .06s; white-space: nowrap; }
|
||||
.btn:hover { background: #252b38; border-color: #36404f; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .5; cursor: default; transform: none; }
|
||||
.btn.primary { background: linear-gradient(180deg, #45adff, #1f7fe5); border-color: #5cb8ff; color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(47, 155, 255, .25); }
|
||||
.btn.primary:hover { background: linear-gradient(180deg, #5cb8ff, #2f8cf0); }
|
||||
.btn.danger { color: #ff9a9d; border-color: #5a2a2e; background: #2a1517; }
|
||||
.btn.small { padding: 6px 12px; font-size: 12.5px; }
|
||||
.btn.wide { width: 100%; }
|
||||
.btn.play { font-size: 22px; letter-spacing: .08em; padding: 16px 56px; border-radius: 14px; }
|
||||
input[type=text], input[type=password], input[type=email], input:not([type]) { width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||
color: var(--text); font: inherit; padding: 10px 12px; border-radius: 10px; outline: none; }
|
||||
input:focus { border-color: var(--accent); }
|
||||
label { display: grid; gap: 6px; font-size: 12.5px; font-weight: 600; color: #b4bdca; }
|
||||
|
||||
/* Schalter */
|
||||
.switch { display: flex; align-items: center; justify-content: space-between; gap: 16px; font-size: 13.5px; font-weight: 600; color: var(--text); cursor: pointer; }
|
||||
.switch input { appearance: none; width: 40px; height: 22px; border-radius: 11px; background: #2a3140; position: relative; cursor: pointer; flex: none;
|
||||
transition: background .15s; }
|
||||
.switch input::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #c4ccd8;
|
||||
transition: transform .15s; }
|
||||
.switch input:checked { background: var(--accent); }
|
||||
.switch input:checked::after { transform: translateX(18px); background: #fff; }
|
||||
.switch small { display: block; font-weight: 400; color: var(--muted); }
|
||||
|
||||
/* ---------------------------------------------------------------- Anmeldung */
|
||||
.auth { position: fixed; inset: var(--titlebar) 0 0 0; display: grid; place-items: center; padding: 16px;
|
||||
background: radial-gradient(900px 500px at 50% -10%, rgba(47, 155, 255, .2), transparent 60%), var(--bg); }
|
||||
.auth-card { width: min(400px, 100%); background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 28px;
|
||||
display: grid; gap: 14px; box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
|
||||
.tabs { display: grid; grid-template-columns: 1fr 1fr; padding: 4px; border-radius: 12px; background: var(--bg); border: 1px solid var(--border); }
|
||||
.tab { border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 700; padding: 8px; border-radius: 9px; cursor: pointer; }
|
||||
.tab.on { background: var(--panel-2); color: var(--text); }
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand img { width: 40px; height: 40px; }
|
||||
.brand.big img { width: 56px; height: 56px; }
|
||||
.brand-title { font-size: 18px; font-weight: 800; letter-spacing: .02em; }
|
||||
.brand.big .brand-title { font-size: 24px; }
|
||||
.brand-sub { color: var(--muted); font-size: 12.5px; }
|
||||
.beta-pill { font-size: 10px; font-weight: 800; letter-spacing: .08em; vertical-align: middle; padding: 2px 6px; border-radius: 6px;
|
||||
color: var(--accent-2); background: rgba(94, 228, 255, .1); border: 1px solid rgba(94, 228, 255, .3); }
|
||||
|
||||
/* ---------------------------------------------------------------- Aufbau */
|
||||
.layout { position: fixed; inset: var(--titlebar) 0 0 0; display: grid; grid-template-columns: 240px 1fr; }
|
||||
.sidebar { background: var(--sidebar); border-right: 1px solid #151922; display: flex; flex-direction: column; padding: 20px 14px; gap: 22px; }
|
||||
nav { display: grid; gap: 4px; }
|
||||
nav button { display: flex; align-items: center; gap: 12px; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 600;
|
||||
padding: 10px 12px; border-radius: 10px; cursor: pointer; text-align: left; }
|
||||
nav button:hover { background: #12161e; color: var(--text); }
|
||||
nav button.on { background: var(--panel-2); color: var(--text); }
|
||||
.nav-icon { font: 16px var(--icons); width: 18px; }
|
||||
.sidebar-foot { margin-top: auto; }
|
||||
.me-card { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 12px; background: var(--panel); border: 1px solid var(--border); }
|
||||
.avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent-2), #1f6fe5); flex: none; }
|
||||
.me-text { display: grid; min-width: 0; flex: 1; }
|
||||
.me-text b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.me-text small { color: var(--muted); font-size: 11.5px; }
|
||||
.icon-btn { border: 0; background: transparent; color: var(--muted); font: 15px var(--icons); padding: 6px; border-radius: 8px; cursor: pointer; }
|
||||
.icon-btn:hover { background: var(--panel-2); color: var(--text); }
|
||||
|
||||
.content { overflow-y: auto; }
|
||||
.page { max-width: 980px; margin: 0 auto; padding: 28px 32px 40px; display: grid; gap: 20px; }
|
||||
|
||||
/* ---------------------------------------------------------------- Spielen */
|
||||
.hero { position: relative; overflow: hidden; border-radius: 18px; border: 1px solid var(--border); padding: 36px;
|
||||
background: radial-gradient(700px 320px at 85% 0%, rgba(94, 228, 255, .16), transparent 60%),
|
||||
radial-gradient(500px 300px at 0% 100%, rgba(31, 111, 229, .22), transparent 60%), var(--panel);
|
||||
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 24px; }
|
||||
.hero h1 { font-size: 32px; }
|
||||
.hero p { margin: 6px 0 0; color: var(--muted); }
|
||||
.hero img { width: 120px; height: 120px; filter: drop-shadow(0 12px 30px rgba(47, 155, 255, .35)); }
|
||||
.hero-actions { grid-column: 1 / -1; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
|
||||
.banner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-radius: 14px;
|
||||
background: rgba(47, 155, 255, .1); border: 1px solid rgba(47, 155, 255, .35); }
|
||||
.banner.warn { background: rgba(242, 194, 48, .08); border-color: rgba(242, 194, 48, .35); }
|
||||
|
||||
.progress { display: grid; gap: 8px; }
|
||||
.bar { height: 8px; border-radius: 4px; background: #232a36; overflow: hidden; }
|
||||
.bar > div { height: 100%; width: 0; background: linear-gradient(90deg, #1f6fe5, var(--accent-2)); transition: width .15s; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; display: grid; gap: 10px; align-content: start; }
|
||||
.card h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); display: flex; align-items: center; gap: 8px; }
|
||||
.card .value { font-size: 16px; font-weight: 700; word-break: break-all; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); display: inline-block; }
|
||||
.dot.ok { background: var(--online); box-shadow: 0 0 10px rgba(61, 214, 140, .6); }
|
||||
.dot.warn { background: var(--pending); }
|
||||
.dot.bad { background: var(--danger); }
|
||||
.row { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* ---------------------------------------------------------------- Neuigkeiten */
|
||||
.release { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; display: grid; gap: 6px; }
|
||||
.release header { display: flex; align-items: baseline; gap: 10px; }
|
||||
.tag { font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; padding: 2px 8px; border-radius: 6px;
|
||||
background: var(--panel-2); color: var(--accent-2); border: 1px solid var(--border); }
|
||||
.release pre { margin: 0; white-space: pre-wrap; font: inherit; color: #c4ccd8; }
|
||||
|
||||
/* ---------------------------------------------------------------- Einstellungen */
|
||||
.settings { display: grid; gap: 14px; }
|
||||
.settings .card { gap: 16px; }
|
||||
|
||||
/* ---------------------------------------------------------------- Meldungen */
|
||||
.toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 100; }
|
||||
.toast { background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); padding: 10px 14px; border-radius: 10px;
|
||||
max-width: 380px; box-shadow: 0 12px 30px rgba(0, 0, 0, .4); animation: in .18s ease-out; }
|
||||
.toast.error { border-left-color: var(--danger); }
|
||||
@keyframes in { from { opacity: 0; transform: translateY(8px); } }
|
||||
@@ -0,0 +1,294 @@
|
||||
"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 ? "" : ""; });
|
||||
|
||||
// ---------------------------------------------------------------- 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, news: renderNews, settings: renderSettings })[name](root);
|
||||
}
|
||||
|
||||
// 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)];
|
||||
if (m.updateAvailable && s.game.found) {
|
||||
const b = h("button", { class: "btn small", type: "button" }, m.installed ? "Aktualisieren" : "Installieren");
|
||||
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));
|
||||
}
|
||||
|
||||
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.loggedIn) { showAuth(); return; }
|
||||
const me = await call("me");
|
||||
if (me.loggedIn) showApp(me.username);
|
||||
else showAuth();
|
||||
})();
|
||||
@@ -0,0 +1,86 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BSO Launcher</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Eigene Titelleiste (das Fenster hat keine von Windows) -->
|
||||
<header class="titlebar">
|
||||
<div class="titlebar-drag" id="titlebarDrag">
|
||||
<img src="logo.png" alt="">
|
||||
<span>BSO Launcher</span>
|
||||
</div>
|
||||
<div class="window-buttons">
|
||||
<button type="button" data-window="minimize" title="Minimieren" aria-label="Minimieren"></button>
|
||||
<button type="button" data-window="maximize" id="maximizeButton" title="Maximieren" aria-label="Maximieren"></button>
|
||||
<button type="button" data-window="close" class="close" title="Schliessen" aria-label="Schliessen"></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Anmeldung / Registrierung -->
|
||||
<main id="auth" class="auth hidden">
|
||||
<form id="authForm" class="auth-card" autocomplete="on">
|
||||
<div class="brand big">
|
||||
<img src="logo.svg" alt="">
|
||||
<div>
|
||||
<div class="brand-title">BSO <span class="beta-pill">BETA</span></div>
|
||||
<div class="brand-sub">Bus Simulator Online</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab on" id="tabLogin">Anmelden</button>
|
||||
<button type="button" class="tab" id="tabRegister">Konto erstellen</button>
|
||||
</div>
|
||||
<label><span>Benutzername</span>
|
||||
<input id="username" autocomplete="username" maxlength="32" required>
|
||||
</label>
|
||||
<label id="emailRow" class="hidden"><span>E-Mail (optional)</span>
|
||||
<input id="email" type="email" autocomplete="email" maxlength="190">
|
||||
</label>
|
||||
<label><span>Passwort</span>
|
||||
<input id="password" type="password" autocomplete="current-password" maxlength="200" required>
|
||||
</label>
|
||||
<label id="password2Row" class="hidden"><span>Passwort wiederholen</span>
|
||||
<input id="password2" type="password" autocomplete="new-password" maxlength="200">
|
||||
</label>
|
||||
<p id="authError" class="error hidden"></p>
|
||||
<button class="btn primary wide" id="authButton" type="submit">Anmelden</button>
|
||||
<p class="muted small center">Dein Benutzername ist auch dein Name im Spiel. Passwoerter liegen nur verschluesselt auf dem BSO-Server.</p>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<!-- Launcher -->
|
||||
<div id="app" class="layout hidden">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="logo.svg" alt="">
|
||||
<div>
|
||||
<div class="brand-title">BSO <span class="beta-pill">BETA</span></div>
|
||||
<div class="brand-sub">Launcher</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav id="nav">
|
||||
<button type="button" data-page="home" class="on"><span class="nav-icon"></span>Spielen</button>
|
||||
<button type="button" data-page="news"><span class="nav-icon"></span>Neuigkeiten</button>
|
||||
<button type="button" data-page="settings"><span class="nav-icon"></span>Einstellungen</button>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<div class="me-card">
|
||||
<span class="avatar" id="meAvatar">?</span>
|
||||
<span class="me-text"><b id="meName"></b><small id="meVersion"></small></span>
|
||||
<button class="icon-btn" id="logout" type="button" title="Abmelden" aria-label="Abmelden"></button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="content">
|
||||
<div id="page" class="page"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="toasts" class="toasts"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
+32
@@ -0,0 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5ee4ff"/>
|
||||
<stop offset="1" stop-color="#1f6fe5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="#0d1016" stroke="url(#g)" stroke-width="3"/>
|
||||
<!-- Funkwellen: online -->
|
||||
<path d="M26.5 15.5a8 8 0 0 1 11 0" fill="none" stroke="url(#g)" stroke-width="2.6" stroke-linecap="round"/>
|
||||
<path d="M23 12a13 13 0 0 1 18 0" fill="none" stroke="url(#g)" stroke-width="2.6" stroke-linecap="round" opacity=".55"/>
|
||||
<circle cx="32" cy="18.6" r="1.9" fill="#5ee4ff"/>
|
||||
<!-- Stadtbus, Front rechts -->
|
||||
<path d="M6.5 44V27.2c0-1.8 1.4-3.2 3.2-3.2h41.4c1.4 0 2.6.9 3.1 2.2l3 7.6c.2.5.3 1 .3 1.6V44z" fill="url(#g)"/>
|
||||
<!-- Fenster, Tuer, Frontscheibe -->
|
||||
<rect x="9.5" y="27" width="7.2" height="6.4" rx="1.2" fill="#0d1016"/>
|
||||
<rect x="18.2" y="27" width="7.2" height="6.4" rx="1.2" fill="#0d1016"/>
|
||||
<rect x="26.9" y="27" width="7.2" height="6.4" rx="1.2" fill="#0d1016"/>
|
||||
<rect x="35.6" y="27" width="7.2" height="6.4" rx="1.2" fill="#0d1016"/>
|
||||
<rect x="44.6" y="27" width="5.4" height="14.5" rx="1" fill="#0d1016"/>
|
||||
<path d="M47.3 27v14.5" stroke="url(#g)" stroke-width=".9"/>
|
||||
<path d="M51.6 27h.9c.6 0 1.1.4 1.3.9l2.5 6.4h-4.7z" fill="#0d1016"/>
|
||||
<!-- Zierstreifen, Scheinwerfer -->
|
||||
<path d="M6.5 37h37.6" stroke="#0d1016" stroke-width="1.6" opacity=".5"/>
|
||||
<rect x="54.4" y="38.4" width="2.8" height="1.9" rx=".8" fill="#fff4c9"/>
|
||||
<!-- Raeder -->
|
||||
<circle cx="16" cy="44" r="5.6" fill="#0d1016"/>
|
||||
<circle cx="39" cy="44" r="5.6" fill="#0d1016"/>
|
||||
<circle cx="16" cy="44" r="3.2" fill="none" stroke="#5ee4ff" stroke-width="2.2"/>
|
||||
<circle cx="39" cy="44" r="3.2" fill="none" stroke="#5ee4ff" stroke-width="2.2"/>
|
||||
<path d="M9 52.5h46" stroke="#232a36" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
Reference in New Issue
Block a user