+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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user