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;
///
/// 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).
///
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();
/// Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).
static readonly HashSet Exclusive = new() { "installMod", "launch", "playLobby", "installLauncherUpdate", "uninstall" };
readonly HashSet running = new();
static readonly HashSet QuietCommands = new() { "window", "status", "me", "init", "releases", "friends", "lobby", "checkLauncherUpdate" };
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;
Lang.Set(settings.Language);
LauncherLog.Info("BSO Launcher " + SelfUpdate.CurrentVersion + " gestartet" + (Api.Dev ? " (-dev)" : "") + ", Website " + Api.Base);
if (Api.Base != Api.BaseUrl) Text += " (DEV)";
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(m.LParam);
base.WndProc(ref m);
var after = Marshal.PtrToStructure(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(Lang.T("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 = Lang.T(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 = Lang.T(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