diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e83274..821f171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Neue Version: hier einen Abschnitt `## ` anlegen, `` in `BSO.L `dotnet publish -c Release -o publish -p:BsoRelease=true` – der Abschnitt wird der Changelog auf der Website und im Launcher. ## 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 - Lobby: kein eigener „Server starten“-Knopf mehr – SPIELEN des Erstellers startet den Server, dann können alle spielen diff --git a/MainForm.cs b/MainForm.cs index 4a49f16..0c52d9d 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -380,6 +380,8 @@ public sealed class MainForm : Form }, dotnet = Game.DotnetInstalled(), launcherUpdate = launcher != null && SelfUpdate.IsNewer(launcher.version, SelfUpdate.CurrentVersion) ? launcher.version : null, + launcherChangelog = launcher?.changelog, + launcherVersion = SelfUpdate.CurrentVersion, masterReachable = mod != null || launcher != null, settings = new { server = settings.Server, autoConnect = settings.AutoConnect, keepOpen = settings.KeepOpen, autoUpdateMod = settings.AutoUpdateMod } }; diff --git a/Program.cs b/Program.cs index 316bf94..93a2539 100644 --- a/Program.cs +++ b/Program.cs @@ -15,12 +15,11 @@ static class Program return 0; } - // Wie Discord: die heruntergeladene .exe installiert sich selbst und startet dann die installierte Kopie. - // Ist schon dieselbe oder eine neuere Version installiert, wird einfach diese geoeffnet. --portable = ohne Installation. + // Die heruntergeladene .exe ist das Setup (Assistent: Ordner, Verknuepfungen, Installieren) und startet danach die + // 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.NeedsInstall()) Application.Run(new InstallForm()); - else Installer.StartInstalled(); + Application.Run(new SetupForm()); return 0; } diff --git a/README.md b/README.md index 88f88f8..e54ee30 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,14 @@ WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebett ## Features -- **Installation wie Discord:** Die heruntergeladene `BSO-Launcher.exe` installiert sich selbst – ohne Admin-Rechte nach - `%LocalAppData%\Programs\BSO Launcher`, mit Verknüpfungen auf dem Desktop und im Startmenü und einem Eintrag unter - *Einstellungen → Apps*. `--portable` startet ohne Installation. +- **Setup-Assistent:** Die heruntergeladene `BSO-Launcher-Setup.exe` (dieselbe signierte Datei wie der Launcher) fragt nach + Sprache, Ordner (Standard `%LocalAppData%\Programs\BSO Launcher`, nur Ordner ohne Admin-Rechte) und Verknüpfungen + (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). 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. - **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. diff --git a/Services/Installer.cs b/Services/Installer.cs index 511b88d..4eba306 100644 --- a/Services/Installer.cs +++ b/Services/Installer.cs @@ -5,9 +5,10 @@ using Microsoft.Win32; namespace BSO.Launcher; /// -/// 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. +/// Installation: die heruntergeladene .exe (BSO-Launcher-Setup.exe) ist zugleich das Setup (SetupForm). Sie kopiert sich +/// ohne Admin-Rechte in den gewaehlten Ordner (Standard %LocalAppData%\Programs\BSO Launcher), legt die gewaehlten +/// Verknuepfungen an, traegt sich unter "Apps & Features" ein (Deinstallieren) und startet dann den installierten Launcher. +/// Der Ordner steht in der Registry (InstallLocation) - danach aktualisiert sich der Launcher dort selbst. /// public static class Installer { @@ -15,15 +16,57 @@ public static class Installer const string ExeName = "BSO-Launcher.exe"; 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); + + /// Ordner der bestehenden Installation (aus "Apps & Features") oder null. + 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); static string Self => Environment.ProcessPath ?? Application.ExecutablePath; + public static long SelfSize => new FileInfo(Self).Length; static string StartMenuShortcut => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName + ".lnk"); static string DesktopShortcut => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), AppName + ".lnk"); + public static bool HasStartMenuShortcut => File.Exists(StartMenuShortcut); + public static bool HasDesktopShortcut => File.Exists(DesktopShortcut); + + /// Gewaehlter Ordner -> Installationsordner: immer ein eigener Unterordner "BSO Launcher" (beim Deinstallieren + /// wird nur der geloescht, nie z.B. "D:\Spiele"). + 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); + } + + /// Darf dieser Benutzer dort schreiben? (Programme ohne Admin-Rechte - auch fuer die Selbst-Updates noetig.) + 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; } + } /// Laeuft gerade die installierte Kopie? public static bool RunningInstalled => @@ -35,23 +78,19 @@ public static class Installer return FileVersionInfo.GetVersionInfo(InstalledExe).ProductVersion?.Split('+')[0]; } - /// Muss installiert (oder aktualisiert) werden? false = installierte Version ist gleich/neuer. - public static bool NeedsInstall() - { - string? installed = InstalledVersion(); - return installed == null || SelfUpdate.IsNewer(SelfUpdate.CurrentVersion, installed); - } - - /// Installieren. progress(0..1, Text). - public static void Install(Action progress) + /// Installieren (auch Update/Neu-Installation, auch in einen anderen Ordner). progress(0..1, Text). + public static void Install(string dir, bool desktop, bool startMenu, Action progress) { + string? oldExe = File.Exists(InstalledExe) ? InstalledExe : null; + string exe = Path.Combine(dir, ExeName); progress(0.1, Lang.T("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; + string? file = p.MainModule?.FileName; + if (!string.Equals(file, exe, StringComparison.OrdinalIgnoreCase) && !string.Equals(file, oldExe, StringComparison.OrdinalIgnoreCase)) continue; p.CloseMainWindow(); if (!p.WaitForExit(3000)) p.Kill(true); } @@ -59,37 +98,49 @@ public static class Installer } progress(0.3, Lang.T("Dateien kopieren …")); - Directory.CreateDirectory(InstallDir); - string tmp = InstalledExe + ".new"; + Directory.CreateDirectory(dir); + string tmp = exe + ".new"; 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 …")); - CreateShortcut(StartMenuShortcut); - CreateShortcut(DesktopShortcut); + Register(dir); // ab hier zeigen InstallDir/InstalledExe auf den neuen Ordner + 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")); } + static void TryDelete(string file) { try { File.Delete(file); } catch { } } + + /// Unsere Dateien im Installationsordner loeschen, den Ordner nur wenn er danach leer ist. + 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 { } + } + /// Eintrag unter "Apps & Features" (bzw. dessen Version nach einem Selbst-Update). - public static void Register() + public static void Register(string? dir = null) { try { + dir ??= InstallDir; + string exe = Path.Combine(dir, ExeName); 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("DisplayIcon", exe + ",0"); + key.SetValue("InstallLocation", dir); + key.SetValue("UninstallString", $"\"{exe}\" --uninstall"); + key.SetValue("QuietUninstallString", $"\"{exe}\" --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 { } + try { key.SetValue("EstimatedSize", (int)(new FileInfo(exe).Length / 1024), RegistryValueKind.DWord); } catch { } } catch { /* Eintrag ist nur Komfort */ } } @@ -119,8 +170,9 @@ public static class Installer Process.Start(start); } - /// Ordner/Dateien loeschen, sobald dieser Prozess beendet ist (seine .exe ist bis dahin gesperrt). - static void RemoveAfterExit(params string[] paths) + /// Ordner/Dateien loeschen, sobald dieser Prozess beendet ist (seine .exe ist bis dahin gesperrt). Danach + /// emptyDir entfernen, aber nur wenn er leer ist (der Benutzer hat den Ordner gewaehlt - fremde Dateien bleiben). + static void RemoveAfterExit(string emptyDir, params string[] paths) { static string Quote(string p) => "'" + p.Replace("'", "''") + "'"; 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; " + $"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 }"; + "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 }; foreach (string a in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script }) psi.ArgumentList.Add(a); @@ -160,7 +213,7 @@ public static class Installer 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); + RemoveAfterExit(dir, Path.Combine(dir, ExeName), Path.Combine(dir, ExeName + ".old"), Path.Combine(dir, ExeName + ".new"), Settings.Folder); if (!quiet) MessageBox.Show(Lang.T(problems.Count == 0 ? "BSO wurde komplett deinstalliert." @@ -175,73 +228,3 @@ public static class Installer return s == null ? null : Image.FromStream(s); } } - -/// Kleines Installationsfenster (wie der Discord-Installer): Logo, Status, Fortschrittsbalken. -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(); - } - } -} diff --git a/Services/Lang.cs b/Services/Lang.cs index e010de8..92b0904 100644 --- a/Services/Lang.cs +++ b/Services/Lang.cs @@ -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."), ("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}"), + ("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"), ("Ordner \"Bus-Simulator 2012\" waehlen", "Choose the \"Bus-Simulator 2012\" folder"), ("Konto konnte nicht geprueft werden: {0}", "Account could not be checked: {0}") diff --git a/Services/SetupForm.cs b/Services/SetupForm.cs new file mode 100644 index 0000000..0c693ec --- /dev/null +++ b/Services/SetupForm.cs @@ -0,0 +1,307 @@ +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; + +namespace BSO.Launcher; + +/// +/// 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. +/// +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 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 + } + + /// Fortschrittsbalken im Launcher-Stil (Verlauf blau -> tuerkis). + 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)); + } + } +} diff --git a/ui/app.css b/ui/app.css index 3af8705..ad04252 100644 --- a/ui/app.css +++ b/ui/app.css @@ -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); } .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 */ .settings { display: grid; gap: 14px; } .settings .card { gap: 16px; } diff --git a/ui/app.js b/ui/app.js index c1090d7..4a94d9e 100644 --- a/ui/app.js +++ b/ui/app.js @@ -447,6 +447,48 @@ async function renderSettings(root) { renderLangSwitch(setLang); } +// ---------------------------------------------------------------- Update-Abfrage + +/** Bei jedem Start: gibt es einen neuen Launcher (oder eine neue Mod)? Dann fragen - "Später" fragt beim naechsten Start wieder. */ +async function askForUpdates() { + let s; + try { s = await call("status"); } catch { return; } + const mod = s.mod.installed && s.mod.updateAvailable && !s.game.running ? s.mod : null; + if (!s.launcherUpdate && !mod) return; + + const item = (name, from, to, changelog) => h("div", { class: "update-item" }, + h("div", {}, h("b", {}, name + " " + to), h("span", { class: "muted small" }, " " + t("installiert: {0}", from))), + h("pre", {}, changelog || t("Keine Beschreibung"))); + const progress = h("div", { class: "progress hidden" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div"))); + const later = h("button", { class: "btn", type: "button" }, t("Später")); + const now = h("button", { class: "btn primary", type: "button" }, t("Jetzt aktualisieren")); + const box = h("div", { class: "modal-back" }, h("div", { class: "modal", role: "dialog" }, + h("h2", {}, t("Update verfügbar")), + h("p", { class: "muted" }, t("Beim Start gefunden – jetzt aktualisieren?")), + s.launcherUpdate ? item("Launcher", s.launcherVersion, s.launcherUpdate, s.launcherChangelog) : null, + mod ? item(t("BSO-Mod"), mod.installed, mod.latest, mod.changelog) : null, + progress, + h("div", { class: "row", style: "justify-content:flex-end" }, later, now))); + document.body.append(box); + now.focus(); + + later.addEventListener("click", () => box.remove()); + now.addEventListener("click", async () => { + now.disabled = later.disabled = true; + progressBox = progress; + try { + if (mod) await call("installMod"); + if (s.launcherUpdate) { await call("installLauncherUpdate"); toast(t("Launcher startet neu …")); return; } // beendet sich gleich + box.remove(); + toast(t("BSO {0} installiert", mod.latest)); + if (!$("app").classList.contains("hidden")) showPage(page); + } catch (e) { + toast(e.message, true); + now.disabled = later.disabled = false; + } + }); +} + // ---------------------------------------------------------------- Start (async () => { @@ -464,6 +506,7 @@ async function renderSettings(root) { $("meVersion").textContent += " · DEV"; } $("authServer").textContent = t("Server: {0}", init.master.replace(/^https?:\/\//, "")); + askForUpdates(); // auch vor dem Anmelden if (!init.loggedIn) { showAuth(); return; } const me = await call("me"); if (me.loggedIn) showApp(me.username); diff --git a/ui/i18n.js b/ui/i18n.js index 80a8635..af270a3 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -45,6 +45,8 @@ const EN = { "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", // 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.", // Einstellungen "Spielordner waehlen": "Choose game folder", "Spielordner gespeichert": "Game folder saved", "Protokoll-Ordner oeffnen": "Open log folder",