diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..152ecf9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +**/bin +**/node_modules +**/obj +publish diff --git a/.gitignore b/.gitignore index 7c172cc..11b8295 100644 --- a/.gitignore +++ b/.gitignore @@ -226,8 +226,9 @@ _pkginfo.txt # but keep track of directories ending in .cache !?*.[Cc]ache/ -# Others -ClientBin/ +# Others +/publish/ +ClientBin/ ~$* *~ *.dbmdl @@ -357,4 +358,4 @@ MigrationBackup/ .store/ # mac-created file to track user view preferences for a directory -.DS_Store \ No newline at end of file +.DS_Store diff --git a/README.md b/README.md index 732fad6..f14e395 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,39 @@ pnpm run build ## Usage +### Run MTGO with Docker + +On a Wayland desktop, start MTGO with: + +```sh +docker compose up mtgo-wayland +``` + +The `mtgo-wayland` service uses the [`videreproject/mtgo:wayland`](https://hub.docker.com/r/videreproject/mtgo) image. On its first run, it downloads and installs MTGO, then stores the Wine prefix and bootstrapper in the `mtgo-wine-data` volume for later runs. + +To follow the service logs or stop the container, use: + +```sh +docker compose logs -f mtgo-wayland +docker compose down +``` + +The service mounts the Wayland socket from the current desktop session. Docker reads its directory from `XDG_RUNTIME_DIR` and uses `wayland-0` when `WAYLAND_DISPLAY` is unset, so start it from a session where `XDG_RUNTIME_DIR` is set. It also mounts the host X11 socket for XWayland fallback and disables WPF hardware acceleration to avoid rendering MTGO windows black under Wine. + +### Run Tracker with MTGO under Wine + +The `tracker-wayland` service builds on `videreproject/mtgosdk:wayland`. Its image installs both the Visual C++ 2015–2022 runtime and Microsoft's x64 Evergreen WebView2 Runtime for the Tracker's embedded web UI. + +To run the Tracker through wine, first build the Windows executable on the host, then build and start the service: + +```sh +pnpm run publish +docker compose build tracker-wayland +docker compose up tracker-wayland +``` + +The service starts both MTGO and the Tracker in the same container and Wine prefix so MTGOSDK can discover the MTGO process. It dalso isables Tracker's installer and starts WebView2 with GPU acceleration disabled, and generates a loopback certificate for Tracker's HTTPS server for local development. Data for the Tracker is persisted in the `tracker-wine-data` volume and is shared in the Wine environment. + ### Development mode To run the Tracker in development mode (with hot reload for the client and debug configuration): diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f9e3bb4 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,50 @@ +services: + mtgo-wayland: + image: videreproject/mtgo:wayland + init: true + stdin_open: true + tty: true + devices: + - /dev/dri:/dev/dri + environment: + DISPLAY: ${DISPLAY:-} + WAYLAND_DISPLAY: ${WAYLAND_DISPLAY:-wayland-0} + XDG_RUNTIME_DIR: /tmp/runtime-dir + WINEDEBUG: -all + command: + - /bin/bash + - /workspace/docker/run-mtgo-wayland.sh + volumes: + - .:/workspace + - mtgo-wine-data:/home/wine/.wine + - ${XDG_RUNTIME_DIR:?XDG_RUNTIME_DIR must be set}/${WAYLAND_DISPLAY:-wayland-0}:/tmp/runtime-dir/${WAYLAND_DISPLAY:-wayland-0} + - /tmp/.X11-unix:/tmp/.X11-unix + + tracker-wayland: + build: + context: . + dockerfile: docker/tracker-wine.Dockerfile + image: videreproject/tracker:wayland-dev + init: true + stdin_open: true + tty: true + devices: + - /dev/dri:/dev/dri + environment: + DISPLAY: ${DISPLAY:-} + WAYLAND_DISPLAY: ${WAYLAND_DISPLAY:-wayland-0} + XDG_RUNTIME_DIR: /tmp/runtime-dir + WINEDEBUG: -all + TRACKER_DISABLE_INSTALLER: "1" + TRACKER_WINE_COMPAT: "1" + APP_THEME: Dark + WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: --disable-gpu + volumes: + - .:/workspace + - tracker-wine-data:/home/wine/.wine + - ${XDG_RUNTIME_DIR:?XDG_RUNTIME_DIR must be set}/${WAYLAND_DISPLAY:-wayland-0}:/tmp/runtime-dir/${WAYLAND_DISPLAY:-wayland-0} + - /tmp/.X11-unix:/tmp/.X11-unix + +volumes: + mtgo-wine-data: + tracker-wine-data: diff --git a/docker/run-mtgo-wayland.sh b/docker/run-mtgo-wayland.sh new file mode 100644 index 0000000..d3f9aa1 --- /dev/null +++ b/docker/run-mtgo-wayland.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +bootstrap=/home/wine/mtgo_setup.exe +persisted_bootstrap=/home/wine/.wine/mtgo_setup.exe + +if [[ ! -f "$bootstrap" && -f "$persisted_bootstrap" ]]; then + cp "$persisted_bootstrap" "$bootstrap" +fi + +if [[ ! -f "$bootstrap" ]]; then + install-mtgo.sh + cp "$bootstrap" "$persisted_bootstrap" +fi + +# MTGO's WPF surface can render black through Wine's accelerated composition. +# Force WPF software rendering in this Wine prefix. +wine reg add 'HKCU\Software\Microsoft\Avalon.Graphics' \ + /v DisableHWAcceleration /t REG_DWORD /d 1 /f >/dev/null + +mtgo +exec wineserver -w diff --git a/docker/run-tracker-wine.sh b/docker/run-tracker-wine.sh new file mode 100644 index 0000000..a6ade4a --- /dev/null +++ b/docker/run-tracker-wine.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -euo pipefail + +tracker_exe='/workspace/publish/Videre Tracker.exe' +tracker_certificate=/opt/tracker/localhost.pfx +skip_mtgo=false +if [[ "${TRACKER_SKIP_MTGO:-0}" =~ ^(1|true|yes|on)$ ]]; then + skip_mtgo=true +fi + +if [[ "$skip_mtgo" == false ]]; then + bootstrap=/home/wine/mtgo_setup.exe + persisted_bootstrap=/home/wine/.wine/mtgo_setup.exe + + if [[ ! -f "$bootstrap" && -f "$persisted_bootstrap" ]]; then + cp "$persisted_bootstrap" "$bootstrap" + fi + + if [[ ! -f "$bootstrap" ]]; then + install-mtgo.sh + cp "$bootstrap" "$persisted_bootstrap" + fi +fi + +if [[ ! -f "$tracker_exe" ]]; then + echo "Tracker executable not found at: $tracker_exe" >&2 + echo "Run 'pnpm run publish' on the host before starting tracker-wayland." >&2 + exit 1 +fi + +# The published app explicitly enables HTTPS. +# Point Kestrel at the loopback-only certificate since Wine has no ASP.NET dev cert. +export ASPNETCORE_Kestrel__Certificates__Default__Path +ASPNETCORE_Kestrel__Certificates__Default__Path="$(winepath -w "$tracker_certificate")" +export ASPNETCORE_Kestrel__Certificates__Default__Password=tracker-localhost + +# Wine's accelerated WPF composition currently renders MTGO as a black surface. +# We instead have WPF fall back to software rendering for compatibility with Wine. +wine reg add 'HKCU\Software\Microsoft\Avalon.Graphics' \ + /v DisableHWAcceleration /t REG_DWORD /d 1 /f >/dev/null + +# +# WebView2 currently attempts DirectComposition when it inherits a modern +# Windows version under Wine. Wine bug 58921 documents that WebView2 renders +# when only msedgewebview2.exe is reported as Windows 8. +# +# See https://bugs.winehq.org/show_bug.cgi?id=58921 for details. +# +wine reg add 'HKCU\Software\Wine\AppDefaults\msedgewebview2.exe' \ + /v Version /t REG_SZ /d win8 /f >/dev/null + +if [[ "$skip_mtgo" == true ]]; then + echo "Skipping MTGO launch (TRACKER_SKIP_MTGO is enabled)." +else + # The ClickOnce bootstrapper detaches after starting MTGO. + mtgo +fi + +echo "Launching Videre Tracker..." +wine "$tracker_exe" & +tracker_pid=$! + +if wait "$tracker_pid"; then + tracker_status=0 +else + tracker_status=$? +fi +wineserver -w +exit "$tracker_status" diff --git a/docker/tracker-wine.Dockerfile b/docker/tracker-wine.Dockerfile new file mode 100644 index 0000000..8121841 --- /dev/null +++ b/docker/tracker-wine.Dockerfile @@ -0,0 +1,62 @@ +FROM videreproject/mtgosdk:wayland AS tracker-prereqs + +ARG WEBVIEW2_RUNTIME_URL=https://go.microsoft.com/fwlink/?linkid=2124701 + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + xauth \ + libegl1 \ + libgl1 \ + libgles2 \ + libgl1-mesa-dri \ + && rm -rf /var/lib/apt/lists/* + +USER wine + +# Tracker's native WebView2 loader uses the current Visual C++ runtime family. +RUN xvfb-run -a winetricks -q --force vcrun2022 + +# The current runtime rejects Wine's Windows 7 default, so configure this +# Tracker-specific prefix as Windows 10 before running Microsoft's installer. +RUN xvfb-run -a winetricks -q win10 + +FROM tracker-prereqs + +ARG WEBVIEW2_RUNTIME_URL=https://go.microsoft.com/fwlink/?linkid=2124701 + +# Install the current x64 Evergreen standalone WebView2 Runtime into the Wine +# prefix. The Microsoft fwlink resolves to the current signed installer. +RUN curl --fail --location --retry 3 \ + --output /tmp/MicrosoftEdgeWebView2RuntimeInstallerX64.exe \ + "${WEBVIEW2_RUNTIME_URL}" \ + && xvfb-run -a wine /tmp/MicrosoftEdgeWebView2RuntimeInstallerX64.exe \ + /silent /install \ + && wineserver -k \ + && find "/home/wine/.wine/drive_c/Program Files (x86)/Microsoft/EdgeWebView/Application" \ + -mindepth 2 -maxdepth 2 -name msedgewebview2.exe -print -quit \ + | grep -q . \ + && rm /tmp/MicrosoftEdgeWebView2RuntimeInstallerX64.exe + +USER root +COPY --chmod=755 docker/run-tracker-wine.sh /usr/local/bin/run-tracker-wine + +# Kestrel needs an explicit certificate because Wine does not provide the +# ASP.NET Core development certificate that UseHttps() normally discovers. +# This certificate is only used by Tracker's loopback server. +RUN mkdir -p /opt/tracker \ + && openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 3650 \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1" \ + -keyout /tmp/tracker-localhost.key \ + -out /tmp/tracker-localhost.crt \ + && openssl pkcs12 -export \ + -out /opt/tracker/localhost.pfx \ + -inkey /tmp/tracker-localhost.key \ + -in /tmp/tracker-localhost.crt \ + -passout pass:tracker-localhost \ + && chmod 0444 /opt/tracker/localhost.pfx \ + && rm /tmp/tracker-localhost.key /tmp/tracker-localhost.crt +USER wine + +CMD ["run-tracker-wine"] diff --git a/src/client/src/layout/LayoutContent.tsx b/src/client/src/layout/LayoutContent.tsx index f19d4e1..26af748 100644 --- a/src/client/src/layout/LayoutContent.tsx +++ b/src/client/src/layout/LayoutContent.tsx @@ -335,7 +335,7 @@ export default function Layout() { const currentBreadcrumbHref = isApiDocsPage ? getApiUrl("/docs") : undefined; return ( - + diff --git a/src/server/packages.lock.json b/src/server/packages.lock.json index ae3b5ff..72ea9ec 100644 --- a/src/server/packages.lock.json +++ b/src/server/packages.lock.json @@ -24,7 +24,9 @@ "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", "dependencies": { "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" } }, "Microsoft.EntityFrameworkCore.Sqlite": { @@ -34,7 +36,10 @@ "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", "dependencies": { "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", "SQLitePCLRaw.core": "2.1.11" } @@ -43,13 +48,16 @@ "type": "Direct", "requested": "[10.0.3, )", "resolved": "10.0.3", - "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==" + "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "ISahzLHsHY7vrwqr2p1YWZ+gsxoBRtH7gWRDK8fDUst9pp2He0GiesaqEfeX0V8QMCJM3eNEHGGpnIcPjFo2NQ==" + "requested": "[10.0.0-rc.2.25502.107, )", + "resolved": "10.0.0-rc.2.25502.107", + "contentHash": "31DsUbLGwks+cdV++WjVY17hq0Dx21KA/F5MQRhBHSgpxFsdEXIJLIe3+2nzH9ZlDtAYR5qhQV5Abm+CIdvdzw==" }, "Microsoft.Web.WebView2": { "type": "Direct", @@ -207,7 +215,10 @@ "resolved": "10.0.3", "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3" + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" } }, "Microsoft.EntityFrameworkCore.Sqlite.Core": { @@ -217,7 +228,10 @@ "dependencies": { "Microsoft.Data.Sqlite.Core": "10.0.3", "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", "SQLitePCLRaw.core": "2.1.11" } }, @@ -226,11 +240,92 @@ "resolved": "10.0.0", "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "10.0.3", "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, "Microsoft.Identity.Client": { "type": "Transitive", "resolved": "4.61.3", diff --git a/src/server/src/ApplicationOptions.cs b/src/server/src/ApplicationOptions.cs index 3d95fa5..63b3040 100644 --- a/src/server/src/ApplicationOptions.cs +++ b/src/server/src/ApplicationOptions.cs @@ -5,6 +5,8 @@ using System; using System.IO; +using System.Linq; +using System.Runtime.InteropServices; namespace Tracker; @@ -71,11 +73,35 @@ public Uri UiUrl public bool IsDevelopment { get; internal set; } = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development"; + /// + /// When set, enables Wine-specific compatibility workarounds (native title + /// bar and software-rendered WebView2). Auto-enabled when running under Wine + /// or when TRACKER_WINE_COMPAT is set to a truthy value. + /// + public bool WineCompatibility { get; internal set; } = + GetBoolEnv("TRACKER_WINE_COMPAT", false) || IsRunningUnderWine(); + /// /// Controls whether to render a custom title bar and window controls. /// public bool UseCustomTitleBar { get; internal set; } = true; + /// + /// Additional Chromium browser arguments passed to the WebView2 runtime. + /// + /// + /// Inherits WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS and, under Wine + /// compatibility mode, appends software-rendering flags to avoid a blank + /// (grey) WebView2 surface. + /// + public string WebView2BrowserArguments + { + get => _webView2BrowserArguments + ?? BuildWebView2BrowserArguments(WineCompatibility); + internal set => _webView2BrowserArguments = value; + } + private string? _webView2BrowserArguments; + /// /// The base directory path for the application's static content. /// @@ -153,4 +179,77 @@ private static string ResolveUserDataFolder() public string VidereAPIUrl { get; internal set; } = Environment.GetEnvironmentVariable("VIDERE_API_URL") ?? "https://api.videreproject.com"; + + /// + /// Parses a boolean environment variable, returning + /// when unset or unparseable. + /// + private static bool GetBoolEnv(string name, bool defaultValue) + { + var raw = Environment.GetEnvironmentVariable(name)?.Trim(); + if (bool.TryParse(raw, out var value)) + { + return value; + } + + return raw switch + { + "1" or "yes" or "on" => true, + "0" or "no" or "off" => false, + _ => defaultValue, + }; + } + + /// + /// Builds the WebView2 browser arguments, appending software-rendering flags + /// when running under Wine compatibility mode. + /// + private static string BuildWebView2BrowserArguments(bool wineCompat) + { + var args = Environment.GetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS") + ?? string.Empty; + + if (!wineCompat) + { + return args.Trim(); + } + + var extra = new[] + { + // WebView2's sandbox initialization is not compatible with Wine. Without + // this flag the renderer can start but never present a painted surface. + "--no-sandbox", + "--disable-dev-shm-usage", + // The API uses the local development certificate generated for the + // container. WebView2 does not inherit Wine's host trust store. + "--allow-insecure-localhost", + "--ignore-certificate-errors", + }; + + var existing = args.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return string.Join(" ", existing.Concat(extra)).Trim(); + } + + /// + /// Detects whether the process is running under Wine by probing for the + /// wine_get_version export in ntdll. + /// + private static bool IsRunningUnderWine() + { + try + { + return GetProcAddress(GetModuleHandle("ntdll.dll"), "wine_get_version") + != IntPtr.Zero; + } + catch + { + return false; + } + } + + [DllImport("kernel32.dll", EntryPoint = "GetModuleHandleA")] + private static extern IntPtr GetModuleHandle(string moduleName); + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi, EntryPoint = "GetProcAddress")] + private static extern IntPtr GetProcAddress(IntPtr hModule, string procName); } diff --git a/src/server/src/WebView/Extensions/WebViewExtensions.cs b/src/server/src/WebView/Extensions/WebViewExtensions.cs index 88f8256..f6eeeed 100644 --- a/src/server/src/WebView/Extensions/WebViewExtensions.cs +++ b/src/server/src/WebView/Extensions/WebViewExtensions.cs @@ -15,15 +15,18 @@ public static class WebViewExtensions /// Explicitly creates the WebView2 environment. /// /// The WebView2 control. - /// The environment options. + /// Additional Chromium arguments. public static void CreateEnvironment(this WebView2 webView, string userDataFolder = default!, - CoreWebView2EnvironmentOptions options = default!) + string additionalBrowserArguments = default!) { var envOptions = CoreWebView2Environment.CreateAsync( browserExecutableFolder: null, // Use the installed WebView2 version. userDataFolder, - options + new CoreWebView2EnvironmentOptions + { + AdditionalBrowserArguments = additionalBrowserArguments + } ); webView.EnsureCoreWebView2Async(envOptions.GetAwaiter().GetResult()); } diff --git a/src/server/src/WebView/HostForm.cs b/src/server/src/WebView/HostForm.cs index 723f6fc..8f1c1a7 100644 --- a/src/server/src/WebView/HostForm.cs +++ b/src/server/src/WebView/HostForm.cs @@ -51,6 +51,11 @@ public partial class HostForm : Form private readonly InvisibleResizePanel[] _resizePanels; private bool _hasUpdatedTitleBarColor; private SplashForm? _splashForm; + private int _navigationRetryAttempts; + private DateTime _lastNavigationErrorLog = DateTime.MinValue; + + private const int NavigationRetryDelayMs = 250; + private const int MaxNavigationRetries = 40; public HostForm(ApplicationOptions options) { @@ -130,10 +135,15 @@ public HostForm(ApplicationOptions options) this.webView21.Dock = DockStyle.Fill; } - // Initialize the WebView2 environment. - WebView.CreateEnvironment(options.UserDataFolder); WebView.CoreWebView2InitializationCompleted += (sender, e) => { + if (!e.IsSuccess) + { + Log.Error(e.InitializationException, + "WebView2 initialization failed (HRESULT={0}).", e.InitializationException?.HResult); + return; + } + WebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; WebView.CoreWebView2.Settings.IsPinchZoomEnabled = false; WebView.CoreWebView2.Settings.IsStatusBarEnabled = false; @@ -162,6 +172,10 @@ public HostForm(ApplicationOptions options) WebView.NavigationCompleted += OnNavigationCompleted; + // Initialize the WebView2 environment after subscribing to completion + // so startup failures and successful initialization remain observable. + WebView.CreateEnvironment(options.UserDataFolder, options.WebView2BrowserArguments); + // Initialize Splash Screen InitializeSplashScreen(); } @@ -170,6 +184,49 @@ private async void OnNavigationCompleted( object? sender, CoreWebView2NavigationCompletedEventArgs e) { + if (!e.IsSuccess) + { + var now = DateTime.UtcNow; + if (now - _lastNavigationErrorLog >= TimeSpan.FromSeconds(1)) + { + Log.Error("WebView2 navigation failed: {0} ({1}); retry {2}/{3}.", + e.WebErrorStatus, e.NavigationId, + Math.Min(_navigationRetryAttempts + 1, MaxNavigationRetries), + MaxNavigationRetries); + _lastNavigationErrorLog = now; + } + + if (_navigationRetryAttempts >= MaxNavigationRetries) + { + Log.Error("WebView2 navigation retry limit reached; leaving the error page visible."); + return; + } + + _navigationRetryAttempts++; + // + // The API thread starts concurrently with the WinForms UI. + // + // As a result, a first navigation can therefore race Kestrel startup; + // instead we retry while this handler remains subscribed instead of + // leaving the host surface blank. + // + try + { + await Task.Delay(NavigationRetryDelayMs); + if (!IsDisposed && WebView.CoreWebView2 is not null) + { + WebView.Reload(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to retry WebView2 navigation."); + } + return; + } + + _navigationRetryAttempts = 0; + var title = WebView.CoreWebView2.DocumentTitle; if (string.IsNullOrEmpty(title)) return;