Setup-Assistent und Update-Abfrage bei jedem Start

Die heruntergeladene Datei ist ein Setup: Sprache, Ordner (nur ohne Admin-Rechte beschreibbar),
Verknuepfungen, Fortschritt, Fertig-Seite; Ordner steht in der Registry. Deinstallieren loescht im
gewaehlten Ordner nur die eigenen Dateien. Beim Start fragt der Launcher nach neuen Updates
(Launcher und Mod).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:01:27 +01:00
co-authored by Claude Opus 5.5
parent 884492f665
commit 5fd162fecb
10 changed files with 461 additions and 109 deletions
+2
View File
@@ -4,6 +4,8 @@ Neue Version: hier einen Abschnitt `## <Version>` anlegen, `<Version>` in `BSO.L
`dotnet publish -c Release -o publish -p:BsoRelease=true` – der Abschnitt wird der Changelog auf der Website und im Launcher. `dotnet publish -c Release -o publish -p:BsoRelease=true` – der Abschnitt wird der Changelog auf der Website und im Launcher.
## 0.3.1 ## 0.3.1
- Setup-Assistent: Sprache, Installationsordner, Desktop-/Startmenü-Verknüpfung wählen, Fortschritt, Fertig-Seite
- Bei jedem Start fragt der Launcher, ob ein neues Update (Launcher oder Mod) installiert werden soll
- Sprache: Deutsch oder Englisch (Einstellungen oder Umschalter auf der Anmeldeseite) – auch Meldungen und Fehler der Website - Sprache: Deutsch oder Englisch (Einstellungen oder Umschalter auf der Anmeldeseite) – auch Meldungen und Fehler der Website
- Lobby: kein eigener „Server starten“-Knopf mehr – SPIELEN des Erstellers startet den Server, dann können alle spielen - Lobby: kein eigener „Server starten“-Knopf mehr – SPIELEN des Erstellers startet den Server, dann können alle spielen
+2
View File
@@ -380,6 +380,8 @@ public sealed class MainForm : Form
}, },
dotnet = Game.DotnetInstalled(), dotnet = Game.DotnetInstalled(),
launcherUpdate = launcher != null && SelfUpdate.IsNewer(launcher.version, SelfUpdate.CurrentVersion) ? launcher.version : null, launcherUpdate = launcher != null && SelfUpdate.IsNewer(launcher.version, SelfUpdate.CurrentVersion) ? launcher.version : null,
launcherChangelog = launcher?.changelog,
launcherVersion = SelfUpdate.CurrentVersion,
masterReachable = mod != null || launcher != null, masterReachable = mod != null || launcher != null,
settings = new { server = settings.Server, autoConnect = settings.AutoConnect, keepOpen = settings.KeepOpen, autoUpdateMod = settings.AutoUpdateMod } settings = new { server = settings.Server, autoConnect = settings.AutoConnect, keepOpen = settings.KeepOpen, autoUpdateMod = settings.AutoUpdateMod }
}; };
+3 -4
View File
@@ -15,12 +15,11 @@ static class Program
return 0; return 0;
} }
// Wie Discord: die heruntergeladene .exe installiert sich selbst und startet dann die installierte Kopie. // Die heruntergeladene .exe ist das Setup (Assistent: Ordner, Verknuepfungen, Installieren) und startet danach die
// Ist schon dieselbe oder eine neuere Version installiert, wird einfach diese geoeffnet. --portable = ohne Installation. // installierte Kopie. Ist schon dieselbe oder eine neuere Version da, bietet es Starten an. --portable = ohne Installation.
if (!Installer.RunningInstalled && !args.Contains("--portable")) if (!Installer.RunningInstalled && !args.Contains("--portable"))
{ {
if (Installer.NeedsInstall()) Application.Run(new InstallForm()); Application.Run(new SetupForm());
else Installer.StartInstalled();
return 0; return 0;
} }
+6 -4
View File
@@ -9,12 +9,14 @@ WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebett
## Features ## Features
- **Installation wie Discord:** Die heruntergeladene `BSO-Launcher.exe` installiert sich selbst – ohne Admin-Rechte nach - **Setup-Assistent:** Die heruntergeladene `BSO-Launcher-Setup.exe` (dieselbe signierte Datei wie der Launcher) fragt nach
`%LocalAppData%\Programs\BSO Launcher`, mit Verknüpfungen auf dem Desktop und im Startmenü und einem Eintrag unter Sprache, Ordner (Standard `%LocalAppData%\Programs\BSO Launcher`, nur Ordner ohne Admin-Rechte) und Verknüpfungen
*Einstellungen → Apps*. `--portable` startet ohne Installation. (Desktop, Startmenü), installiert mit Fortschrittsanzeige und trägt sich unter *Einstellungen → Apps* ein. Ist dieselbe
oder eine neuere Version installiert, bietet es Starten an. `--portable` startet ohne Installation.
- **BSO-Konto:** Registrieren und Anmelden bei der BSO-Website. Die Anmeldung bleibt 30 Tage (Token DPAPI-verschlüsselt). - **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. Der Kontoname ist der Name im Spiel.
- **Auto-Update:** neueste Mod-Version vor jedem Start (abschaltbar), Selbst-Update des Launchers mit einem Klick. - **Auto-Update:** Bei jedem Start fragt der Launcher, ob ein neues Update (Launcher oder Mod) installiert werden soll;
dazu neueste Mod-Version vor jedem Spielstart (abschaltbar).
Downloads sind fortsetzbar, SHA-256-geprüft und müssen mit dem **BSO-Release-Schlüssel signiert** sein. 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). - **Prüft alles:** Spielordner (alle Steam-Bibliotheken), BSO-Version, .NET 10 Runtime (braucht die Mod).
- **Freunde & Lobby:** Freunde per Benutzername hinzufügen (online-Anzeige), Lobby erstellen, einladen - SPIELEN des Erstellers startet den Lobby-Server. - **Freunde & Lobby:** Freunde per Benutzername hinzufügen (online-Anzeige), Lobby erstellen, einladen - SPIELEN des Erstellers startet den Lobby-Server.
+84 -101
View File
@@ -5,9 +5,10 @@ using Microsoft.Win32;
namespace BSO.Launcher; namespace BSO.Launcher;
/// <summary> /// <summary>
/// Installation wie bei Discord (wie beim MSCO Launcher): die heruntergeladene .exe installiert sich selbst ohne /// Installation: die heruntergeladene .exe (BSO-Launcher-Setup.exe) ist zugleich das Setup (SetupForm). Sie kopiert sich
/// Admin-Rechte nach %LocalAppData%\Programs\BSO Launcher, legt Verknuepfungen (Desktop, Startmenue) an, traegt sich unter /// ohne Admin-Rechte in den gewaehlten Ordner (Standard %LocalAppData%\Programs\BSO Launcher), legt die gewaehlten
/// "Apps &amp; Features" ein (Deinstallieren) und startet dann den installierten Launcher. /// Verknuepfungen an, traegt sich unter "Apps &amp; Features" ein (Deinstallieren) und startet dann den installierten Launcher.
/// Der Ordner steht in der Registry (InstallLocation) - danach aktualisiert sich der Launcher dort selbst.
/// </summary> /// </summary>
public static class Installer public static class Installer
{ {
@@ -15,15 +16,57 @@ public static class Installer
const string ExeName = "BSO-Launcher.exe"; const string ExeName = "BSO-Launcher.exe";
const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\BSO-Launcher"; const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\BSO-Launcher";
public static string InstallDir => public static string DefaultDir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName); Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName);
/// <summary>Ordner der bestehenden Installation (aus "Apps &amp; Features") oder null.</summary>
public static string? RegisteredDir
{
get
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(UninstallKey);
return key?.GetValue("InstallLocation") is string dir && dir.Length > 0 ? dir : null;
}
catch { return null; }
}
}
public static string InstallDir => RegisteredDir ?? DefaultDir;
public static string InstalledExe => Path.Combine(InstallDir, ExeName); public static string InstalledExe => Path.Combine(InstallDir, ExeName);
static string Self => Environment.ProcessPath ?? Application.ExecutablePath; static string Self => Environment.ProcessPath ?? Application.ExecutablePath;
public static long SelfSize => new FileInfo(Self).Length;
static string StartMenuShortcut => static string StartMenuShortcut =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName + ".lnk"); Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName + ".lnk");
static string DesktopShortcut => static string DesktopShortcut =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), AppName + ".lnk"); Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), AppName + ".lnk");
public static bool HasStartMenuShortcut => File.Exists(StartMenuShortcut);
public static bool HasDesktopShortcut => File.Exists(DesktopShortcut);
/// <summary>Gewaehlter Ordner -> Installationsordner: immer ein eigener Unterordner "BSO Launcher" (beim Deinstallieren
/// wird nur der geloescht, nie z.B. "D:\Spiele").</summary>
public static string NormalizeDir(string chosen)
{
string dir = Path.GetFullPath(Environment.ExpandEnvironmentVariables(chosen.Trim().Trim('"')));
return string.Equals(Path.GetFileName(dir.TrimEnd('\\')), AppName, StringComparison.OrdinalIgnoreCase) ? dir : Path.Combine(dir, AppName);
}
/// <summary>Darf dieser Benutzer dort schreiben? (Programme ohne Admin-Rechte - auch fuer die Selbst-Updates noetig.)</summary>
public static string? CheckWritable(string dir)
{
try
{
Directory.CreateDirectory(dir);
string probe = Path.Combine(dir, ".bso-write-test");
File.WriteAllText(probe, "");
File.Delete(probe);
return null;
}
catch (UnauthorizedAccessException) { return "Keine Schreibrechte in diesem Ordner - bitte einen Ordner waehlen, in dem du ohne Admin-Rechte schreiben darfst"; }
catch (Exception e) { return "Ordner nicht nutzbar: " + e.Message; }
}
/// <summary>Laeuft gerade die installierte Kopie?</summary> /// <summary>Laeuft gerade die installierte Kopie?</summary>
public static bool RunningInstalled => public static bool RunningInstalled =>
@@ -35,23 +78,19 @@ public static class Installer
return FileVersionInfo.GetVersionInfo(InstalledExe).ProductVersion?.Split('+')[0]; return FileVersionInfo.GetVersionInfo(InstalledExe).ProductVersion?.Split('+')[0];
} }
/// <summary>Muss installiert (oder aktualisiert) werden? false = installierte Version ist gleich/neuer.</summary> /// <summary>Installieren (auch Update/Neu-Installation, auch in einen anderen Ordner). progress(0..1, Text).</summary>
public static bool NeedsInstall() public static void Install(string dir, bool desktop, bool startMenu, Action<double, string> progress)
{
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)
{ {
string? oldExe = File.Exists(InstalledExe) ? InstalledExe : null;
string exe = Path.Combine(dir, ExeName);
progress(0.1, Lang.T("Laufenden Launcher schliessen …")); progress(0.1, Lang.T("Laufenden Launcher schliessen …"));
foreach (var p in Process.GetProcessesByName("BSO-Launcher")) foreach (var p in Process.GetProcessesByName("BSO-Launcher"))
{ {
try try
{ {
if (p.Id == Environment.ProcessId) continue; if (p.Id == Environment.ProcessId) continue;
if (!string.Equals(p.MainModule?.FileName, InstalledExe, StringComparison.OrdinalIgnoreCase)) continue; string? file = p.MainModule?.FileName;
if (!string.Equals(file, exe, StringComparison.OrdinalIgnoreCase) && !string.Equals(file, oldExe, StringComparison.OrdinalIgnoreCase)) continue;
p.CloseMainWindow(); p.CloseMainWindow();
if (!p.WaitForExit(3000)) p.Kill(true); if (!p.WaitForExit(3000)) p.Kill(true);
} }
@@ -59,37 +98,49 @@ public static class Installer
} }
progress(0.3, Lang.T("Dateien kopieren …")); progress(0.3, Lang.T("Dateien kopieren …"));
Directory.CreateDirectory(InstallDir); Directory.CreateDirectory(dir);
string tmp = InstalledExe + ".new"; string tmp = exe + ".new";
File.Copy(Self, tmp, true); File.Copy(Self, tmp, true);
File.Move(tmp, InstalledExe, true); File.Move(tmp, exe, true);
// Vorher woanders installiert: die alte Kopie wegraeumen (nur unsere Dateien, den Ordner nur wenn leer)
if (oldExe != null && !string.Equals(oldExe, exe, StringComparison.OrdinalIgnoreCase)) RemoveFiles(Path.GetDirectoryName(oldExe)!);
progress(0.6, Lang.T("Verknuepfungen anlegen …")); progress(0.6, Lang.T("Verknuepfungen anlegen …"));
CreateShortcut(StartMenuShortcut); Register(dir); // ab hier zeigen InstallDir/InstalledExe auf den neuen Ordner
CreateShortcut(DesktopShortcut); if (startMenu) CreateShortcut(StartMenuShortcut); else TryDelete(StartMenuShortcut);
if (desktop) CreateShortcut(DesktopShortcut); else TryDelete(DesktopShortcut);
progress(0.8, Lang.T("Registrieren …"));
Register();
progress(1.0, Lang.T("Fertig")); progress(1.0, Lang.T("Fertig"));
} }
static void TryDelete(string file) { try { File.Delete(file); } catch { } }
/// <summary>Unsere Dateien im Installationsordner loeschen, den Ordner nur wenn er danach leer ist.</summary>
static void RemoveFiles(string dir)
{
foreach (string name in new[] { ExeName, ExeName + ".old", ExeName + ".new" }) TryDelete(Path.Combine(dir, name));
try { if (!Directory.EnumerateFileSystemEntries(dir).Any()) Directory.Delete(dir); } catch { }
}
/// <summary>Eintrag unter "Apps &amp; Features" (bzw. dessen Version nach einem Selbst-Update).</summary> /// <summary>Eintrag unter "Apps &amp; Features" (bzw. dessen Version nach einem Selbst-Update).</summary>
public static void Register() public static void Register(string? dir = null)
{ {
try try
{ {
dir ??= InstallDir;
string exe = Path.Combine(dir, ExeName);
using var key = Registry.CurrentUser.CreateSubKey(UninstallKey); using var key = Registry.CurrentUser.CreateSubKey(UninstallKey);
key.SetValue("DisplayName", AppName); key.SetValue("DisplayName", AppName);
key.SetValue("DisplayVersion", SelfUpdate.CurrentVersion); key.SetValue("DisplayVersion", SelfUpdate.CurrentVersion);
key.SetValue("Publisher", "MBM.wtf"); key.SetValue("Publisher", "MBM.wtf");
key.SetValue("DisplayIcon", InstalledExe + ",0"); key.SetValue("DisplayIcon", exe + ",0");
key.SetValue("InstallLocation", InstallDir); key.SetValue("InstallLocation", dir);
key.SetValue("UninstallString", $"\"{InstalledExe}\" --uninstall"); key.SetValue("UninstallString", $"\"{exe}\" --uninstall");
key.SetValue("QuietUninstallString", $"\"{InstalledExe}\" --uninstall --quiet"); key.SetValue("QuietUninstallString", $"\"{exe}\" --uninstall --quiet");
key.SetValue("URLInfoAbout", Api.BaseUrl); key.SetValue("URLInfoAbout", Api.BaseUrl);
key.SetValue("NoModify", 1, RegistryValueKind.DWord); key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord); key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
try { key.SetValue("EstimatedSize", (int)(new FileInfo(InstalledExe).Length / 1024), RegistryValueKind.DWord); } catch { } try { key.SetValue("EstimatedSize", (int)(new FileInfo(exe).Length / 1024), RegistryValueKind.DWord); } catch { }
} }
catch { /* Eintrag ist nur Komfort */ } catch { /* Eintrag ist nur Komfort */ }
} }
@@ -119,8 +170,9 @@ public static class Installer
Process.Start(start); Process.Start(start);
} }
/// <summary>Ordner/Dateien loeschen, sobald dieser Prozess beendet ist (seine .exe ist bis dahin gesperrt).</summary> /// <summary>Ordner/Dateien loeschen, sobald dieser Prozess beendet ist (seine .exe ist bis dahin gesperrt). Danach
static void RemoveAfterExit(params string[] paths) /// emptyDir entfernen, aber nur wenn er leer ist (der Benutzer hat den Ordner gewaehlt - fremde Dateien bleiben).</summary>
static void RemoveAfterExit(string emptyDir, params string[] paths)
{ {
static string Quote(string p) => "'" + p.Replace("'", "''") + "'"; static string Quote(string p) => "'" + p.Replace("'", "''") + "'";
string list = string.Join(",", paths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(Quote)); string list = string.Join(",", paths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(Quote));
@@ -128,7 +180,8 @@ public static class Installer
$"Wait-Process -Id {Environment.ProcessId} -ErrorAction SilentlyContinue; " + $"Wait-Process -Id {Environment.ProcessId} -ErrorAction SilentlyContinue; " +
$"foreach ($i in 1..30) {{ $left = $false; foreach ($p in @({list})) {{ " + $"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) { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue; " +
"if (Test-Path -LiteralPath $p) { $left = $true } } }; if (-not $left) { break }; Start-Sleep -Seconds 1 }"; "if (Test-Path -LiteralPath $p) { $left = $true } } }; if (-not $left) { break }; Start-Sleep -Seconds 1 }; " +
$"$d = {Quote(emptyDir)}; if ((Test-Path -LiteralPath $d) -and -not (Get-ChildItem -LiteralPath $d -Force)) {{ Remove-Item -LiteralPath $d -Force }}";
var psi = new ProcessStartInfo("powershell.exe") { CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden }; 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 }) foreach (string a in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script })
psi.ArgumentList.Add(a); psi.ArgumentList.Add(a);
@@ -160,7 +213,7 @@ public static class Installer
try { File.Delete(DesktopShortcut); } catch { } try { File.Delete(DesktopShortcut); } catch { }
try { Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, false); } catch { } try { Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, false); } catch { }
// Die laufende .exe laesst sich nicht selbst loeschen: aufraeumen, sobald dieser Prozess beendet ist // Die laufende .exe laesst sich nicht selbst loeschen: aufraeumen, sobald dieser Prozess beendet ist
RemoveAfterExit(dir, Settings.Folder); RemoveAfterExit(dir, Path.Combine(dir, ExeName), Path.Combine(dir, ExeName + ".old"), Path.Combine(dir, ExeName + ".new"), Settings.Folder);
if (!quiet) if (!quiet)
MessageBox.Show(Lang.T(problems.Count == 0 MessageBox.Show(Lang.T(problems.Count == 0
? "BSO wurde komplett deinstalliert." ? "BSO wurde komplett deinstalliert."
@@ -175,73 +228,3 @@ public static class Installer
return s == null ? null : Image.FromStream(s); 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 = Lang.T("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 = Lang.T("Installation fehlgeschlagen: " + e.Message);
Invalidate();
await Task.Delay(6000);
Close();
}
}
}
+3
View File
@@ -53,6 +53,9 @@ public static class Lang
("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Dateien in Benutzung.", "Close Bus-Simulator 2012 first - its files are in use while it runs."), ("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Dateien in Benutzung.", "Close Bus-Simulator 2012 first - its files are in use while it runs."),
("BSO wurde komplett deinstalliert.", "BSO was uninstalled completely."), ("BSO wurde komplett deinstalliert.", "BSO was uninstalled completely."),
("BSO wurde deinstalliert. Einiges liess sich nicht entfernen:\n\n{0}", "BSO was uninstalled. Some things could not be removed:\n\n{0}"), ("BSO wurde deinstalliert. Einiges liess sich nicht entfernen:\n\n{0}", "BSO was uninstalled. Some things could not be removed:\n\n{0}"),
("Keine Schreibrechte in diesem Ordner - bitte einen Ordner waehlen, in dem du ohne Admin-Rechte schreiben darfst",
"No write access to this folder - please choose a folder you can write to without admin rights"),
("Ordner nicht nutzbar: {0}", "Folder not usable: {0}"),
("Neuigkeiten nicht verfuegbar", "News not available"), ("Neuigkeiten nicht verfuegbar", "News not available"),
("Ordner \"Bus-Simulator 2012\" waehlen", "Choose the \"Bus-Simulator 2012\" folder"), ("Ordner \"Bus-Simulator 2012\" waehlen", "Choose the \"Bus-Simulator 2012\" folder"),
("Konto konnte nicht geprueft werden: {0}", "Account could not be checked: {0}") ("Konto konnte nicht geprueft werden: {0}", "Account could not be checked: {0}")
+307
View File
@@ -0,0 +1,307 @@
using System.Drawing.Drawing2D;
using System.Runtime.InteropServices;
namespace BSO.Launcher;
/// <summary>
/// Setup-Assistent der heruntergeladenen BSO-Launcher-Setup.exe: Willkommen (mit Sprachwahl) -> Ordner und Verknuepfungen
/// -> Installieren -> Fertig. Ist dieselbe oder eine neuere Version schon installiert, bietet er Starten an (gleiche Version:
/// auch Neu installieren). Eigenes dunkles Fenster im Stil des Launchers.
/// </summary>
public sealed class SetupForm : Form
{
static readonly Color Back = Color.FromArgb(0x10, 0x13, 0x1a), Field = Color.FromArgb(0x1a, 0x1f, 0x29), Line = Color.FromArgb(0x26, 0x2c, 0x38),
Bright = Color.FromArgb(0xe8, 0xec, 0xf2), Muted = Color.FromArgb(0x8b, 0x95, 0xa5), Accent = Color.FromArgb(0x1f, 0x6f, 0xe5),
Bad = Color.FromArgb(0xff, 0x8a, 0x8d);
const int Header = 96;
readonly string version = SelfUpdate.CurrentVersion;
readonly string? installed = Installer.InstalledVersion();
readonly FlowLayoutPanel content = new(), buttons = new(), langs = new();
readonly Label close = new();
readonly Image? logo = Installer.Logo();
Action page;
bool installing;
// Auswahl - bleibt beim Sprachwechsel und Zurueck erhalten
string folder = Installer.RegisteredDir ?? Installer.DefaultDir;
bool desktop, startMenu;
string? installedTo;
static string L(string de, string en) => Lang.Current == "en" ? en : de;
public SetupForm()
{
bool fresh = installed == null;
desktop = fresh || Installer.HasDesktopShortcut;
startMenu = fresh || Installer.HasStartMenuShortcut;
Text = "BSO Launcher Setup";
FormBorderStyle = FormBorderStyle.None;
StartPosition = FormStartPosition.CenterScreen;
AutoScaleMode = AutoScaleMode.Dpi;
ClientSize = new Size(600, 430);
BackColor = Back;
ForeColor = Bright;
Font = new Font("Segoe UI", 10f);
DoubleBuffered = true;
try { Icon = Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? Application.ExecutablePath); } catch { }
content.SetBounds(32, Header + 18, 536, 250);
content.FlowDirection = FlowDirection.TopDown;
content.WrapContents = false;
buttons.SetBounds(32, 368, 536, 44);
buttons.FlowDirection = FlowDirection.RightToLeft;
buttons.WrapContents = false;
langs.SetBounds(460, 34, 96, 30);
langs.FlowDirection = FlowDirection.LeftToRight;
langs.WrapContents = false;
close.SetBounds(560, 8, 32, 28);
close.Text = "✕";
close.ForeColor = Muted;
close.TextAlign = ContentAlignment.MiddleCenter;
close.Cursor = Cursors.Hand;
close.Click += (_, _) => { if (!installing) Close(); };
close.MouseEnter += (_, _) => close.ForeColor = Bright;
close.MouseLeave += (_, _) => close.ForeColor = Muted;
Controls.AddRange(new Control[] { content, buttons, langs, close });
MouseDown += (_, e) => { if (e.Button == MouseButtons.Left && e.Y < Header) DragWindow(); };
page = Welcome;
Go(Welcome);
}
// ---------------------------------------------------------------- Seiten
void Welcome()
{
if (installed != null && !SelfUpdate.IsNewer(version, installed))
{
bool same = installed == version;
Title(L("BSO Launcher ist schon installiert", "BSO Launcher is already installed"));
Paragraph(same
? L($"Version {installed} ist auf diesem PC schon installiert. Du kannst ihn starten oder neu installieren.",
$"Version {installed} is already installed on this PC. You can start it or install it again.")
: L($"Auf diesem PC ist schon die neuere Version {installed} installiert (dieses Setup: {version}).",
$"The newer version {installed} is already installed on this PC (this setup: {version})."));
Paragraph(Installer.InstallDir, Muted, 9f);
Button(L("Launcher starten", "Start launcher"), true, () => { Installer.StartInstalled(); Close(); });
if (same) Button(L("Neu installieren", "Reinstall"), false, () => Go(Options));
return;
}
if (installed != null)
{
Title(L($"Update auf Version {version}", $"Update to version {version}"));
Paragraph(L($"Installiert ist Version {installed}. Das Setup aktualisiert den BSO Launcher – deine Anmeldung und Einstellungen bleiben erhalten.",
$"Version {installed} is installed. This setup updates the BSO Launcher – your sign-in and settings are kept."));
}
else
{
Title(L("Willkommen", "Welcome"));
Paragraph(L($"Dieses Setup installiert den BSO Launcher {version} – Multiplayer für Bus-Simulator 2012.",
$"This setup installs the BSO Launcher {version} – multiplayer for Bus-Simulator 2012."));
Paragraph(L("Der Launcher installiert danach die BSO-Mod für dich und hält sich und die Mod selbst aktuell. Admin-Rechte brauchst du nicht.",
"The launcher then installs the BSO mod for you and keeps itself and the mod up to date. No admin rights needed."));
}
Paragraph(L("Sprache wählst du oben rechts.", "Choose your language in the top right."), Muted, 9f);
Button(L("Weiter", "Next"), true, () => Go(Options));
Button(L("Abbrechen", "Cancel"), false, Close);
}
void Options()
{
Title(L("Ordner und Verknüpfungen", "Folder and shortcuts"));
Paragraph(L("Installieren in:", "Install to:"), Muted, 9f, 0);
var row = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0, 4, 0, 4) };
var path = new TextBox { Text = folder, Width = 404, BackColor = Field, ForeColor = Bright, BorderStyle = BorderStyle.FixedSingle, Margin = new Padding(0, 2, 8, 0) };
path.TextChanged += (_, _) => folder = path.Text;
var browse = MakeButton(L("Ändern …", "Change …"), false, () =>
{
using var dialog = new FolderBrowserDialog { Description = L("Ordner für den BSO Launcher wählen", "Choose a folder for the BSO Launcher"), UseDescriptionForTitle = true };
try { dialog.InitialDirectory = Directory.Exists(folder) ? folder : Path.GetDirectoryName(folder) ?? ""; } catch { }
if (dialog.ShowDialog(this) == DialogResult.OK) path.Text = Installer.NormalizeDir(dialog.SelectedPath);
});
browse.MinimumSize = new Size(110, 30);
row.Controls.AddRange(new Control[] { path, browse });
content.Controls.Add(row);
Paragraph(L($"Braucht etwa {Installer.SelfSize / 1024 / 1024} MB · nur für deinen Windows-Benutzer",
$"Needs about {Installer.SelfSize / 1024 / 1024} MB · only for your Windows user"), Muted, 9f);
Check(L("Verknüpfung auf dem Desktop", "Desktop shortcut"), desktop, v => desktop = v);
Check(L("Eintrag im Startmenü", "Start menu entry"), startMenu, v => startMenu = v);
var error = Paragraph("", Bad, 9.5f);
Button(L("Installieren", "Install"), true, () =>
{
string dir;
try { dir = Installer.NormalizeDir(folder); }
catch { error.Text = L("Ungültiger Ordner", "Invalid folder"); return; }
if (Installer.CheckWritable(dir) is { } problem) { error.Text = Lang.T(problem); return; }
Go(() => Install(dir));
});
Button(L("Zurück", "Back"), false, () => Go(Welcome));
}
void Install(string dir)
{
Title(L("Wird installiert …", "Installing …"));
var status = Paragraph(Lang.T("Vorbereiten …"), Muted, 9.5f);
var bar = new Bar { Width = 536, Height = 8, Margin = new Padding(0, 8, 0, 0) };
content.Controls.Add(bar);
installing = true;
langs.Enabled = false;
_ = Task.Run(() =>
{
try
{
Installer.Install(dir, desktop, startMenu, (p, text) => BeginInvoke(() => { bar.Value = p; status.Text = text; }));
installedTo = dir;
BeginInvoke(() => { installing = false; langs.Enabled = true; Go(Finish); });
}
catch (Exception e)
{
BeginInvoke(() => { installing = false; langs.Enabled = true; Go(() => Failed(e.Message)); });
}
});
}
void Failed(string message)
{
Title(L("Installation fehlgeschlagen", "Installation failed"));
Paragraph(message, Bad);
Paragraph(L("Läuft der Launcher noch? Dann schliessen und nochmal versuchen.", "Is the launcher still running? Close it and try again."), Muted, 9f);
Button(L("Schliessen", "Close"), false, Close);
Button(L("Zurück", "Back"), true, () => Go(Options));
}
void Finish()
{
Title(L("Fertig!", "Done!"));
Paragraph(L($"Der BSO Launcher {version} ist installiert.", $"The BSO Launcher {version} is installed."));
Paragraph(installedTo ?? Installer.InstallDir, Muted, 9f);
Paragraph(L("Deinstallieren kannst du ihn jederzeit in den Launcher-Einstellungen oder unter Windows-Einstellungen → Apps.",
"You can uninstall it any time in the launcher settings or under Windows Settings → Apps."), Muted, 9f);
bool start = true;
Check(L("BSO Launcher jetzt starten", "Start BSO Launcher now"), true, v => start = v);
Button(L("Fertig", "Finish"), true, () => { if (start) Installer.StartInstalled(); Close(); });
}
// ---------------------------------------------------------------- Bausteine
void Go(Action next)
{
page = next;
Invalidate(); // Kopf (Sprache)
SuspendLayout();
content.Controls.Clear();
buttons.Controls.Clear();
DrawLangs();
next();
ResumeLayout();
}
void DrawLangs()
{
langs.Controls.Clear();
foreach (string l in new[] { "de", "en" })
{
var b = MakeButton(l.ToUpperInvariant(), Lang.Current == l, () =>
{
Lang.Set(l);
try { var s = Settings.Load(); s.Language = l; s.Save(); } catch { } // gleich auch fuer den Launcher
Go(page);
});
b.AutoSize = false;
b.MinimumSize = Size.Empty;
b.Size = new Size(44, 28);
b.Padding = Padding.Empty;
b.Margin = new Padding(0, 0, 4, 0);
b.Font = new Font("Segoe UI", 8.5f, FontStyle.Bold);
langs.Controls.Add(b);
}
}
void Title(string text) => Paragraph(text, Bright, 15f, 6, FontStyle.Bold);
Label Paragraph(string text, Color? color = null, float size = 10f, int below = 8, FontStyle style = FontStyle.Regular)
{
var label = new Label
{
Text = text, AutoSize = true, MaximumSize = new Size(536, 0), ForeColor = color ?? Bright,
Font = new Font("Segoe UI", size, style), Margin = new Padding(0, 0, 0, below), UseMnemonic = false
};
content.Controls.Add(label);
return label;
}
void Check(string text, bool value, Action<bool> changed)
{
var box = new CheckBox { Text = text, Checked = value, AutoSize = true, ForeColor = Bright, Margin = new Padding(0, 4, 0, 2), Cursor = Cursors.Hand };
box.CheckedChanged += (_, _) => changed(box.Checked);
content.Controls.Add(box);
}
void Button(string text, bool primary, Action click) => buttons.Controls.Add(MakeButton(text, primary, click));
static Button MakeButton(string text, bool primary, Action click)
{
var b = new Button
{
Text = text, AutoSize = true, MinimumSize = new Size(120, 36), FlatStyle = FlatStyle.Flat, Cursor = Cursors.Hand,
BackColor = primary ? Accent : Field, ForeColor = Color.White, Font = new Font("Segoe UI", 9.5f, FontStyle.Bold),
Margin = new Padding(10, 0, 0, 0), Padding = new Padding(12, 0, 12, 0), UseMnemonic = false
};
b.FlatAppearance.BorderSize = primary ? 0 : 1;
b.FlatAppearance.BorderColor = Line;
b.Click += (_, _) => click();
return b;
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
using (var border = new Pen(Line)) { g.DrawRectangle(border, 0, 0, ClientSize.Width - 1, ClientSize.Height - 1); g.DrawLine(border, 0, Header, ClientSize.Width, Header); }
if (logo != null) g.DrawImage(logo, 28, 22, 52, 52);
using var title = new Font("Segoe UI", 15f, FontStyle.Bold);
TextRenderer.DrawText(g, "BSO Launcher", title, new Point(92, 20), Bright);
using var sub = new Font("Segoe UI", 9f);
TextRenderer.DrawText(g, L($"Setup · Version {version}", $"Setup · version {version}"), sub, new Point(94, 52), Muted);
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (installing && e.CloseReason == CloseReason.UserClosing) e.Cancel = true; // nicht mitten im Kopieren abbrechen
base.OnFormClosing(e);
}
[DllImport("user32.dll")] static extern bool ReleaseCapture();
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
void DragWindow()
{
ReleaseCapture();
SendMessage(Handle, 0x00A1, 2, IntPtr.Zero); // WM_NCLBUTTONDOWN, HTCAPTION
}
/// <summary>Fortschrittsbalken im Launcher-Stil (Verlauf blau -> tuerkis).</summary>
sealed class Bar : Control
{
double value;
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public double Value { get => value; set { this.value = Math.Clamp(value, 0, 1); Invalidate(); } }
public Bar() { DoubleBuffered = true; }
protected override void OnPaint(PaintEventArgs e)
{
var all = new Rectangle(0, 0, Width, Height);
using (var back = new SolidBrush(Color.FromArgb(0x23, 0x2a, 0x36))) e.Graphics.FillRectangle(back, all);
if (value <= 0) return;
using var fill = new LinearGradientBrush(all, Color.FromArgb(0x1f, 0x6f, 0xe5), Color.FromArgb(0x5e, 0xe4, 0xff), 0f);
e.Graphics.FillRectangle(fill, new Rectangle(0, 0, (int)(Width * value), Height));
}
}
}
+9
View File
@@ -178,6 +178,15 @@ nav button.on { background: var(--panel-2); color: var(--text); }
background: var(--panel-2); color: var(--accent-2); border: 1px solid var(--border); } 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; } .release pre { margin: 0; white-space: pre-wrap; font: inherit; color: #c4ccd8; }
/* Update-Abfrage beim Start */
.modal-back { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: rgba(5, 7, 10, .72); }
.modal { width: min(520px, 100%); max-height: calc(100vh - 48px); overflow: auto; display: grid; gap: 14px; background: var(--panel);
border: 1px solid var(--border); border-radius: 18px; padding: 24px; box-shadow: 0 24px 64px rgba(0, 0, 0, .5); }
.modal h2 { margin: 0; }
.modal p { margin: 0; }
.update-item { display: grid; gap: 6px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; }
.update-item pre { margin: 0; max-height: 160px; overflow: auto; white-space: pre-wrap; font: inherit; font-size: 13px; color: #c4ccd8; }
/* ---------------------------------------------------------------- Einstellungen */ /* ---------------------------------------------------------------- Einstellungen */
.settings { display: grid; gap: 14px; } .settings { display: grid; gap: 14px; }
.settings .card { gap: 16px; } .settings .card { gap: 16px; }
+43
View File
@@ -447,6 +447,48 @@ async function renderSettings(root) {
renderLangSwitch(setLang); renderLangSwitch(setLang);
} }
// ---------------------------------------------------------------- Update-Abfrage
/** Bei jedem Start: gibt es einen neuen Launcher (oder eine neue Mod)? Dann fragen - "Später" fragt beim naechsten Start wieder. */
async function askForUpdates() {
let s;
try { s = await call("status"); } catch { return; }
const mod = s.mod.installed && s.mod.updateAvailable && !s.game.running ? s.mod : null;
if (!s.launcherUpdate && !mod) return;
const item = (name, from, to, changelog) => h("div", { class: "update-item" },
h("div", {}, h("b", {}, name + " " + to), h("span", { class: "muted small" }, " " + t("installiert: {0}", from))),
h("pre", {}, changelog || t("Keine Beschreibung")));
const progress = h("div", { class: "progress hidden" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div")));
const later = h("button", { class: "btn", type: "button" }, t("Später"));
const now = h("button", { class: "btn primary", type: "button" }, t("Jetzt aktualisieren"));
const box = h("div", { class: "modal-back" }, h("div", { class: "modal", role: "dialog" },
h("h2", {}, t("Update verfügbar")),
h("p", { class: "muted" }, t("Beim Start gefunden – jetzt aktualisieren?")),
s.launcherUpdate ? item("Launcher", s.launcherVersion, s.launcherUpdate, s.launcherChangelog) : null,
mod ? item(t("BSO-Mod"), mod.installed, mod.latest, mod.changelog) : null,
progress,
h("div", { class: "row", style: "justify-content:flex-end" }, later, now)));
document.body.append(box);
now.focus();
later.addEventListener("click", () => box.remove());
now.addEventListener("click", async () => {
now.disabled = later.disabled = true;
progressBox = progress;
try {
if (mod) await call("installMod");
if (s.launcherUpdate) { await call("installLauncherUpdate"); toast(t("Launcher startet neu …")); return; } // beendet sich gleich
box.remove();
toast(t("BSO {0} installiert", mod.latest));
if (!$("app").classList.contains("hidden")) showPage(page);
} catch (e) {
toast(e.message, true);
now.disabled = later.disabled = false;
}
});
}
// ---------------------------------------------------------------- Start // ---------------------------------------------------------------- Start
(async () => { (async () => {
@@ -464,6 +506,7 @@ async function renderSettings(root) {
$("meVersion").textContent += " · DEV"; $("meVersion").textContent += " · DEV";
} }
$("authServer").textContent = t("Server: {0}", init.master.replace(/^https?:\/\//, "")); $("authServer").textContent = t("Server: {0}", init.master.replace(/^https?:\/\//, ""));
askForUpdates(); // auch vor dem Anmelden
if (!init.loggedIn) { showAuth(); return; } if (!init.loggedIn) { showAuth(); return; }
const me = await call("me"); const me = await call("me");
if (me.loggedIn) showApp(me.username); if (me.loggedIn) showApp(me.username);
+2
View File
@@ -45,6 +45,8 @@ const EN = {
"Freund entfernen": "Remove friend", "{0} als Freund entfernen?": "Remove {0} as a friend?", "eingeladen": "invited", "Einladen": "Invite", "Freund entfernen": "Remove friend", "{0} als Freund entfernen?": "Remove {0} as a friend?", "eingeladen": "invited", "Einladen": "Invite",
"in der Lobby": "in the lobby", "Gesendet": "Sent", "wartet …": "pending …", "Zurückziehen": "Withdraw", "in der Lobby": "in the lobby", "Gesendet": "Sent", "wartet …": "pending …", "Zurückziehen": "Withdraw",
// Neuigkeiten // Neuigkeiten
"Update verfügbar": "Update available", "Beim Start gefunden – jetzt aktualisieren?": "Found at startup – update now?",
"installiert: {0}": "installed: {0}", "Später": "Later", "BSO-Mod": "BSO mod",
"Keine Beschreibung": "No description", "Noch keine Versionen veroeffentlicht.": "No versions published yet.", "Keine Beschreibung": "No description", "Noch keine Versionen veroeffentlicht.": "No versions published yet.",
// Einstellungen // Einstellungen
"Spielordner waehlen": "Choose game folder", "Spielordner gespeichert": "Game folder saved", "Protokoll-Ordner oeffnen": "Open log folder", "Spielordner waehlen": "Choose game folder", "Spielordner gespeichert": "Game folder saved", "Protokoll-Ordner oeffnen": "Open log folder",