Files
metin-launcher/src/Metin2Launcher/Bootstrap/NativeLibraryBootstrap.cs
Jan Nedbal 6212559b41 launcher: native DLL bootstrap for single-file under Wine
.NET 8 publish --self-contained -p:PublishSingleFile=true with
-p:IncludeNativeLibrariesForSelfExtract=true bundles the Avalonia /
SkiaSharp native libs inside the bundle and extracts them at startup
to $DOTNET_BUNDLE_EXTRACT_BASE_DIR/<app>/<hash>/ (default
%TEMP%/.net/<app>/<hash>/). On native Windows the CLR adds that path
to the DLL search list automatically, so Avalonia can P/Invoke into
libSkiaSharp.dll without any help.

Under Wine the search path is never updated and P/Invokes from
Avalonia.Skia hit DllNotFoundException for sk_colortype_get_default_8888
before the first frame renders. The launcher crashes in
Avalonia.AppBuilder.SetupUnsafe before any GUI comes up, so the user
just sees "failed to open" style errors with no actionable context.

Fix: the first line of Program.Main locates the extract dir via
DOTNET_BUNDLE_EXTRACT_BASE_DIR env var + app name + a sentinel
(libSkiaSharp.dll) and calls SetDllDirectoryW on it. That puts the
extract dir on the DLL search path for any subsequent LoadLibrary,
which is what Avalonia's P/Invokes end up doing.

Verified under wine-staging 10.15 on Fedora:
- pure single-file .exe in an otherwise empty directory
- no accompanying loose DLLs
- no env var overrides at all
- launcher boots, Avalonia window renders, orchestrator verifies
  manifest 2026.04.15-m2pack-v7 signature, download begins

No-op on native Windows (SetDllDirectoryW is a supported API and the
extract dir is already in the search path, so re-adding it is
harmless). No-op in dev builds where DOTNET_BUNDLE_EXTRACT_BASE_DIR
is unset and the extract dir probe returns null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:34:55 +02:00

89 lines
3.7 KiB
C#

using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Metin2Launcher.Bootstrap;
/// <summary>
/// Pre-resolves the native DLL search path so LoadLibrary calls from
/// Avalonia / SkiaSharp find their deps when running as a .NET 8
/// single-file self-contained executable under Wine.
///
/// Why this exists:
///
/// - .NET 8 single-file bundles the native libs (libSkiaSharp.dll,
/// libHarfBuzzSharp.dll, libsodium.dll, av_libglesv2.dll) inside the
/// exe and extracts them at startup to
/// <c>$DOTNET_BUNDLE_EXTRACT_BASE_DIR/&lt;app&gt;/&lt;hash&gt;/</c>
/// (default: <c>%TEMP%/.net/&lt;app&gt;/&lt;hash&gt;/</c>).
/// - On native Windows, the CLR calls AddDllDirectory on the extract
/// dir so LoadLibrary picks them up.
/// - Under Wine, either wine's %TEMP% mapping trips over tmpfs during
/// extract (we observed a thread stack overflow in virtual_setup_exception
/// before Main even runs, with the default %TEMP%), or the DLL
/// search path never learns about the extract dir, and Avalonia's
/// P/Invoke hits DllNotFoundException when SkiaSharp tries to load.
///
/// The fix is two-part:
///
/// 1. Externally: set <c>DOTNET_BUNDLE_EXTRACT_BASE_DIR</c> to a path
/// under the exe's own directory, so the extract writes onto the
/// real filesystem (not wine's tmpfs).
/// 2. Here: once the CLR is up and Main has been reached, iterate
/// <see cref="Process.GetCurrentProcess"/>.Modules, find a loaded
/// module that lives inside the extract dir, and call
/// <c>SetDllDirectoryW</c> with that directory so subsequent
/// LoadLibrary calls (from Avalonia, SkiaSharp, NSec, etc.)
/// resolve their native deps.
///
/// The bootstrap is a no-op if we cannot identify an extract dir
/// (e.g. on native Windows where the default resolver already works,
/// or when the launcher is not running as a single-file bundle).
/// </summary>
internal static class NativeLibraryBootstrap
{
[DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool SetDllDirectoryW(string? lpPathName);
public static void Apply()
{
try
{
var extractDir = FindExtractDir();
if (string.IsNullOrEmpty(extractDir))
return;
SetDllDirectoryW(extractDir);
}
catch
{
// Intentionally swallow — bootstrap is best-effort. If it
// fails the launcher will surface the real DllNotFoundException
// from Avalonia later with a stack trace.
}
}
// Strategy: look at DOTNET_BUNDLE_EXTRACT_BASE_DIR + app name and
// pick the single hash-suffixed subdirectory that contains a file
// with a name like libSkiaSharp.dll. Avoids depending on which
// native libs are already loaded at Main() entry (they usually are
// not).
private static string? FindExtractDir()
{
var baseDir = Environment.GetEnvironmentVariable("DOTNET_BUNDLE_EXTRACT_BASE_DIR");
if (string.IsNullOrEmpty(baseDir))
return null;
var appName = Path.GetFileNameWithoutExtension(Environment.ProcessPath ?? "Metin2Launcher");
var appBase = Path.Combine(baseDir, appName);
if (!Directory.Exists(appBase))
return null;
// There should be exactly one hash-suffixed subdir per binary.
// Pick whichever one contains the Skia native lib.
foreach (var candidate in Directory.EnumerateDirectories(appBase))
{
if (File.Exists(Path.Combine(candidate, "libSkiaSharp.dll")))
return candidate;
}
// Fall back to whichever subdir exists first — better than nothing.
return Directory.EnumerateDirectories(appBase).FirstOrDefault();
}
}