+399
@@ -0,0 +1,399 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Web.WebView2.WinForms;
|
||||
|
||||
namespace BSO.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster mit WebView2 (wie beim MSCO Launcher): die Oberflaeche (ui/*.html/css/js, in der .exe eingebettet) laeuft unter
|
||||
/// https://app.bso/ und spricht ueber Nachrichten { id, cmd, args } mit dem Launcher; Antworten { id, ok, result | error },
|
||||
/// Ereignisse { event, ... } (z.B. Download-Fortschritt).
|
||||
/// </summary>
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
const string Host = "app.bso";
|
||||
readonly WebView2 web = new() { Dock = DockStyle.Fill, DefaultBackgroundColor = Color.FromArgb(0x0b, 0x0d, 0x12) };
|
||||
readonly Settings settings = Settings.Load();
|
||||
|
||||
/// <summary>Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).</summary>
|
||||
static readonly HashSet<string> Exclusive = new() { "installMod", "launch", "installLauncherUpdate", "uninstall" };
|
||||
readonly HashSet<string> running = new();
|
||||
static readonly HashSet<string> QuietCommands = new() { "window", "status", "me", "init", "releases" };
|
||||
bool updateInstalled;
|
||||
static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "BSO Launcher";
|
||||
BackColor = Color.FromArgb(0x0b, 0x0d, 0x12);
|
||||
ClientSize = new Size(1120, 700);
|
||||
MinimumSize = new Size(900, 600);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
LauncherLog.Info("BSO Launcher " + SelfUpdate.CurrentVersion + " gestartet");
|
||||
try { Icon = Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? Application.ExecutablePath); } catch { }
|
||||
Controls.Add(web);
|
||||
Load += async (_, _) => await InitWeb();
|
||||
Resize += (_, _) => Emit(new { @event = "window", maximized = WindowState == FormWindowState.Maximized });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ohne Windows-Titelleiste
|
||||
// Die Titelleiste zeichnet die Oberflaeche selbst (ziehen, minimieren, maximieren, schliessen). Der Rahmen bleibt:
|
||||
// das Fenster laesst sich weiter mit der Maus vergroessern, Aero Snap und Schatten bleiben.
|
||||
|
||||
const int WM_NCCALCSIZE = 0x0083, WM_NCLBUTTONDOWN = 0x00A1, HTCAPTION = 2;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
[DllImport("user32.dll")] static extern bool ReleaseCapture();
|
||||
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||
[DllImport("user32.dll")] static extern int GetSystemMetricsForDpi(int index, uint dpi);
|
||||
[DllImport("user32.dll")] static extern uint GetDpiForWindow(IntPtr hwnd);
|
||||
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
||||
[DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
try
|
||||
{
|
||||
int dark = 1, round = 2; // dunkler Rahmen, abgerundete Ecken (Windows 11)
|
||||
DwmSetWindowAttribute(Handle, 20, ref dark, 4);
|
||||
DwmSetWindowAttribute(Handle, 33, ref round, 4);
|
||||
}
|
||||
catch { }
|
||||
SetWindowPos(Handle, IntPtr.Zero, 0, 0, 0, 0, 0x0020 | 0x0002 | 0x0001 | 0x0004); // FRAMECHANGED|NOMOVE|NOSIZE|NOZORDER
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
if (m.Msg == WM_NCCALCSIZE && m.WParam != IntPtr.Zero)
|
||||
{
|
||||
// Nur die Titelleiste wegnehmen: oben beginnt der Inhalt direkt am Fensterrand
|
||||
var before = Marshal.PtrToStructure<RECT>(m.LParam);
|
||||
base.WndProc(ref m);
|
||||
var after = Marshal.PtrToStructure<RECT>(m.LParam);
|
||||
after.Top = before.Top;
|
||||
if (WindowState == FormWindowState.Maximized)
|
||||
{
|
||||
// Maximiert ragt das Fenster um den Rahmen ueber den Bildschirm hinaus
|
||||
uint dpi = GetDpiForWindow(Handle);
|
||||
after.Top += GetSystemMetricsForDpi(33, dpi) + GetSystemMetricsForDpi(92, dpi); // SM_CYFRAME + SM_CXPADDEDBORDER
|
||||
}
|
||||
Marshal.StructureToPtr(after, m.LParam, false);
|
||||
m.Result = IntPtr.Zero;
|
||||
return;
|
||||
}
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
void WindowCommand(string action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "drag":
|
||||
ReleaseCapture();
|
||||
SendMessage(Handle, WM_NCLBUTTONDOWN, HTCAPTION, IntPtr.Zero);
|
||||
break;
|
||||
case "minimize":
|
||||
WindowState = FormWindowState.Minimized;
|
||||
break;
|
||||
case "maximize":
|
||||
WindowState = WindowState == FormWindowState.Maximized ? FormWindowState.Normal : FormWindowState.Maximized;
|
||||
break;
|
||||
case "close":
|
||||
Close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async Task InitWeb()
|
||||
{
|
||||
try
|
||||
{
|
||||
var env = await CoreWebView2Environment.CreateAsync(null, Path.Combine(Settings.Folder, "WebView2"));
|
||||
await web.EnsureCoreWebView2Async(env);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var answer = MessageBox.Show("Die Microsoft Edge WebView2 Runtime fehlt (" + e.Message + ").\n\nDownload-Seite oeffnen?",
|
||||
Installer.AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Error);
|
||||
if (answer == DialogResult.Yes) OpenUrl("https://developer.microsoft.com/microsoft-edge/webview2/");
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
var core = web.CoreWebView2;
|
||||
core.Settings.AreDevToolsEnabled = Debugger.IsAttached;
|
||||
core.Settings.AreDefaultContextMenusEnabled = Debugger.IsAttached;
|
||||
core.Settings.IsZoomControlEnabled = false;
|
||||
core.Settings.IsStatusBarEnabled = false;
|
||||
core.Settings.AreBrowserAcceleratorKeysEnabled = Debugger.IsAttached;
|
||||
try { core.Settings.IsNonClientRegionSupportEnabled = true; } catch { } // CSS "app-region: drag" = Titelleiste
|
||||
core.AddWebResourceRequestedFilter($"https://{Host}/*", CoreWebView2WebResourceContext.All);
|
||||
core.WebResourceRequested += ServeUi;
|
||||
// Befehle nur von der eigenen Oberflaeche (nicht aus fremden Seiten oder eingebetteten Frames)
|
||||
core.WebMessageReceived += async (_, e) =>
|
||||
{
|
||||
if (!e.Source.StartsWith($"https://{Host}/", StringComparison.Ordinal)) return;
|
||||
await OnMessage(e.WebMessageAsJson);
|
||||
};
|
||||
// Links nach aussen im Browser oeffnen, nicht im Launcher
|
||||
core.NewWindowRequested += (_, e) => { e.Handled = true; OpenUrl(e.Uri); };
|
||||
core.NavigationStarting += (_, e) =>
|
||||
{
|
||||
if (!e.Uri.StartsWith($"https://{Host}/")) { e.Cancel = true; OpenUrl(e.Uri); }
|
||||
};
|
||||
core.Navigate($"https://{Host}/index.html");
|
||||
}
|
||||
|
||||
static void OpenUrl(string url)
|
||||
{
|
||||
if (url.StartsWith("https://") || url.StartsWith("http://"))
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
void ServeUi(object? sender, CoreWebView2WebResourceRequestedEventArgs e)
|
||||
{
|
||||
string path = new Uri(e.Request.Uri).AbsolutePath.TrimStart('/');
|
||||
if (path.Length == 0) path = "index.html";
|
||||
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("ui/" + path);
|
||||
if (stream == null)
|
||||
{
|
||||
e.Response = web.CoreWebView2.Environment.CreateWebResourceResponse(null, 404, "Not found", "");
|
||||
return;
|
||||
}
|
||||
string type = Path.GetExtension(path) switch
|
||||
{
|
||||
".html" => "text/html; charset=utf-8",
|
||||
".js" => "text/javascript; charset=utf-8",
|
||||
".css" => "text/css; charset=utf-8",
|
||||
".png" => "image/png",
|
||||
".svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
e.Response = web.CoreWebView2.Environment.CreateWebResourceResponse(stream, 200, "OK",
|
||||
$"Content-Type: {type}\nCache-Control: no-store\nX-Content-Type-Options: nosniff\nContent-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Nachrichten aus der Oberflaeche
|
||||
|
||||
void Emit(object message)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
string json = JsonSerializer.Serialize(message, Json);
|
||||
if (InvokeRequired) BeginInvoke(() => web.CoreWebView2?.PostWebMessageAsJson(json));
|
||||
else web.CoreWebView2?.PostWebMessageAsJson(json);
|
||||
}
|
||||
|
||||
void Progress(string task, double value, string text) => Emit(new { @event = "progress", task, value, text });
|
||||
|
||||
async Task OnMessage(string raw)
|
||||
{
|
||||
int id = 0;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var msg = doc.RootElement;
|
||||
id = msg.GetProperty("id").GetInt32();
|
||||
string cmd = msg.GetProperty("cmd").GetString() ?? "";
|
||||
JsonElement args = msg.TryGetProperty("args", out var a) ? a.Clone() : default;
|
||||
if (!QuietCommands.Contains(cmd)) LauncherLog.Info("> " + cmd);
|
||||
object? result;
|
||||
if (Exclusive.Contains(cmd))
|
||||
{
|
||||
lock (running)
|
||||
if (!running.Add(cmd)) throw new Exception("Laeuft schon - bitte warten");
|
||||
try { result = await HandleCommand(cmd, args); }
|
||||
finally
|
||||
{
|
||||
// Launcher-Update erfolgreich: der Launcher beendet sich gleich - nicht wieder freigeben
|
||||
if (cmd != "installLauncherUpdate" || !updateInstalled) lock (running) running.Remove(cmd);
|
||||
}
|
||||
}
|
||||
else result = await HandleCommand(cmd, args);
|
||||
Emit(new { id, ok = true, result });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LauncherLog.Error(e.Message);
|
||||
Emit(new { id, ok = false, error = e.Message });
|
||||
}
|
||||
}
|
||||
|
||||
static string Arg(JsonElement args, string name) =>
|
||||
args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
|
||||
|
||||
static bool? BoolArg(JsonElement args, string name) =>
|
||||
args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? v.GetBoolean() : null;
|
||||
|
||||
async Task<object?> HandleCommand(string cmd, JsonElement args)
|
||||
{
|
||||
switch (cmd)
|
||||
{
|
||||
case "window":
|
||||
WindowCommand(Arg(args, "action"));
|
||||
return null;
|
||||
case "init":
|
||||
return new
|
||||
{
|
||||
maximized = WindowState == FormWindowState.Maximized,
|
||||
version = SelfUpdate.CurrentVersion,
|
||||
username = settings.Username,
|
||||
loggedIn = settings.Token != null,
|
||||
master = Api.Base
|
||||
};
|
||||
|
||||
// --- Konto
|
||||
case "login":
|
||||
case "register":
|
||||
{
|
||||
var r = cmd == "login"
|
||||
? await Api.Login(Arg(args, "username"), Arg(args, "password"))
|
||||
: await Api.Register(Arg(args, "username"), Arg(args, "password"), Arg(args, "email") is { Length: > 0 } mail ? mail : null);
|
||||
if (!r.Ok) throw new Exception(r.Error ?? "Fehlgeschlagen");
|
||||
settings.Token = r.Json.GetProperty("token").GetString();
|
||||
settings.Username = r.Json.GetProperty("account").GetProperty("username").GetString();
|
||||
settings.Save();
|
||||
LauncherLog.Info((cmd == "login" ? "Angemeldet als " : "Registriert als ") + settings.Username);
|
||||
return new { username = settings.Username };
|
||||
}
|
||||
case "me":
|
||||
{
|
||||
string? token = settings.Token;
|
||||
if (token == null) return new { loggedIn = false };
|
||||
var r = await Api.Me(token);
|
||||
if (r.Status == 401) { settings.Token = null; settings.Save(); return new { loggedIn = false }; }
|
||||
// Master gerade nicht erreichbar: angemeldet bleiben
|
||||
return new { loggedIn = true, username = r.Ok ? r.Json.GetProperty("account").GetProperty("username").GetString() : settings.Username, offline = !r.Ok };
|
||||
}
|
||||
case "logout":
|
||||
{
|
||||
string? token = settings.Token;
|
||||
if (token != null) await Api.Logout(token);
|
||||
settings.Token = null;
|
||||
settings.Save();
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Status, Installieren, Spielen
|
||||
case "status":
|
||||
return await Status();
|
||||
case "installMod":
|
||||
return await InstallMod();
|
||||
case "launch":
|
||||
return await Launch();
|
||||
case "releases":
|
||||
{
|
||||
var r = await Api.Releases();
|
||||
if (!r.Ok) throw new Exception(r.Error ?? "Neuigkeiten nicht verfuegbar");
|
||||
return r.Json.GetProperty("releases");
|
||||
}
|
||||
case "installLauncherUpdate":
|
||||
{
|
||||
var latest = await Api.Latest("launcher") ?? throw new Exception("Kein Launcher-Update gefunden");
|
||||
string version = await SelfUpdate.Install(latest, p => Progress("launcher", p, "Launcher-Update wird geladen …"));
|
||||
updateInstalled = true;
|
||||
LauncherLog.Info("Launcher aktualisiert auf " + version);
|
||||
BeginInvoke(Close); // die neue Version startet gerade
|
||||
return new { version };
|
||||
}
|
||||
|
||||
// --- Einstellungen
|
||||
case "setSettings":
|
||||
if (args.ValueKind == JsonValueKind.Object && args.TryGetProperty("server", out var s) && s.ValueKind == JsonValueKind.String)
|
||||
settings.Server = s.GetString()!.Trim();
|
||||
settings.AutoConnect = BoolArg(args, "autoConnect") ?? settings.AutoConnect;
|
||||
settings.KeepOpen = BoolArg(args, "keepOpen") ?? settings.KeepOpen;
|
||||
settings.AutoUpdateMod = BoolArg(args, "autoUpdateMod") ?? settings.AutoUpdateMod;
|
||||
settings.Save();
|
||||
return null;
|
||||
case "chooseGameFolder":
|
||||
{
|
||||
using var dialog = new FolderBrowserDialog { Description = "Ordner \"Bus-Simulator 2012\" waehlen", UseDescriptionForTitle = true };
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK) return new { changed = false };
|
||||
string folder = dialog.SelectedPath;
|
||||
// Auch Bin_High_Win64 selbst gewaehlt? Dann den Ordner darueber nehmen
|
||||
if (!Game.IsGameFolder(folder) && Game.IsGameFolder(Path.GetDirectoryName(folder))) folder = Path.GetDirectoryName(folder)!;
|
||||
if (!Game.IsGameFolder(folder)) throw new Exception("In diesem Ordner ist kein Bus-Simulator 2012 (Bin_High_Win64\\BusSimulator2012.exe fehlt)");
|
||||
settings.GameFolder = folder;
|
||||
settings.Save();
|
||||
return new { changed = true };
|
||||
}
|
||||
case "openDotnet":
|
||||
OpenUrl(Game.DotnetDownload);
|
||||
return null;
|
||||
case "openLog":
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{Settings.Folder}\"") { UseShellExecute = true });
|
||||
return null;
|
||||
case "uninstall":
|
||||
if (Installer.Uninstall(false)) BeginInvoke(Close);
|
||||
return null;
|
||||
}
|
||||
throw new Exception("Unbekannter Befehl: " + cmd);
|
||||
}
|
||||
|
||||
async Task<object> Status()
|
||||
{
|
||||
string? folder = Game.FindFolder(settings);
|
||||
var modTask = Api.Latest("mod");
|
||||
var launcherTask = Api.Latest("launcher");
|
||||
await Task.WhenAll(modTask, launcherTask);
|
||||
Api.Release? mod = modTask.Result, launcher = launcherTask.Result;
|
||||
string? installed = folder != null ? Game.ModVersion(folder) : null;
|
||||
return new
|
||||
{
|
||||
game = new { found = folder != null, folder, running = Game.IsRunning() },
|
||||
mod = new
|
||||
{
|
||||
installed,
|
||||
latest = mod?.version,
|
||||
changelog = mod?.changelog,
|
||||
updateAvailable = mod != null && (installed == null || SelfUpdate.IsNewer(mod.version, installed))
|
||||
},
|
||||
dotnet = Game.DotnetInstalled(),
|
||||
launcherUpdate = launcher != null && SelfUpdate.IsNewer(launcher.version, SelfUpdate.CurrentVersion) ? launcher.version : null,
|
||||
masterReachable = mod != null || launcher != null,
|
||||
settings = new { server = settings.Server, autoConnect = settings.AutoConnect, keepOpen = settings.KeepOpen, autoUpdateMod = settings.AutoUpdateMod }
|
||||
};
|
||||
}
|
||||
|
||||
async Task<object> InstallMod()
|
||||
{
|
||||
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen");
|
||||
var latest = await Api.Latest("mod") ?? throw new Exception("Noch keine BSO-Version auf dem Server");
|
||||
byte[] zip = await Api.Download(latest, p => Progress("mod", p, $"BSO {latest.version} wird geladen …"));
|
||||
Progress("mod", 1, "Installieren …");
|
||||
Game.InstallMod(folder, zip);
|
||||
return new { version = latest.version };
|
||||
}
|
||||
|
||||
async Task<object> Launch()
|
||||
{
|
||||
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen");
|
||||
if (!Game.DotnetInstalled()) throw new Exception("Fuer BSO fehlt die .NET 10 Runtime (x64) - unter \"Status\" herunterladen");
|
||||
if (Game.IsRunning()) throw new Exception("Bus-Simulator 2012 laeuft schon");
|
||||
|
||||
// Vorher aktualisieren (oder installieren, wenn BSO noch fehlt)
|
||||
string? installed = Game.ModVersion(folder);
|
||||
if (installed == null || settings.AutoUpdateMod)
|
||||
{
|
||||
var latest = await Api.Latest("mod");
|
||||
if (latest != null && (installed == null || SelfUpdate.IsNewer(latest.version, installed))) await InstallMod();
|
||||
else if (installed == null) throw new Exception("BSO ist nicht installiert und der BSO-Server ist nicht erreichbar");
|
||||
}
|
||||
|
||||
Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect);
|
||||
Game.Launch();
|
||||
LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})");
|
||||
if (!settings.KeepOpen)
|
||||
{
|
||||
await Task.Delay(2500);
|
||||
BeginInvoke(Close);
|
||||
}
|
||||
return new { started = true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user