Erste Version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:26:25 +01:00
co-authored by Claude Opus 5.5
commit b69b9638ff
20 changed files with 1863 additions and 0 deletions
+161
View File
@@ -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 (&lt;sha256&gt;.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");
}
}
+118
View File
@@ -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;
}
}
+244
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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();
}
}
}
+26
View File
@@ -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 { }
}
}
}
+32
View File
@@ -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; }
}
}
+51
View File
@@ -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;
}
}
+56
View File
@@ -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);
}
}