using System; using ADOFAIRenderer.Localization; using System.IO; using System.IO.Compression; using System.Net.Http; using System.Text; using System.Threading.Tasks; using MelonLoader; using MelonLoader.Utils; using O5Kit.Core; using O5Kit.Factory; using TMPro; using UnityEngine; using UnityEngine.UI; namespace ADOFAIRenderer.UI; /// /// Modal dialog that installs the FFmpeg shared libraries into MelonLoader's UserLibs folder. /// /// Shown by FirstUpdateHook when reports missing libraries. On Windows and Linux /// the "Download" button downloads a prebuilt BtbN FFmpeg build (GPL, shared libraries) and extracts the libraries /// into UserLibs; on macOS the button opens https://ffmpeg.org, because the dialog text tells the user to build FFmpeg /// manually there. The libraries are found by FFmpeg.AutoGen through ffmpeg.RootPath (set to UserLibs in /// RenderMod.OnInitializeMelon). /// /// /// Threads: UI construction and all Unity object access happen on the main Unity thread. The download and extraction /// run on thread-pool threads (); they post UI updates to /// , which drains on the main Unity thread. /// /// /// Flow: buttons -> (Download) progress bar + status text -> download to a temporary archive in UserLibs -> extract /// libraries -> "complete" for 1 second -> dialog destroyed. On failure the status shows the error and the buttons /// return so the user can retry or cancel. Cancel destroys the dialog at once and cancels a running download. /// /// internal class FFmpegDownloadUI : MonoBehaviour { /// Row with the Download/Open Website and Cancel buttons; hidden while a download runs. private GameObject _buttonContainerObj = null!; /// Progress bar background (rounded, theme colors); shown when the download starts. private GameObject _progressBarObj = null!; /// Progress bar fill object. private GameObject _progressBarFillObj = null!; /// Fill rect; its anchorMax.x is the progress fraction 0..1 (see ). private RectTransform _fillRect = null!; /// Text centered on the progress bar (percentage or "Extracting..."). private TextMeshProUGUI _progressText = null!; /// Status line below the progress bar; shown when the download starts. private GameObject _statusTextObj = null!; /// Status message, colored by state (gray starting, cyan downloading, yellow extracting, green done, red failed). private TextMeshProUGUI _statusText = null!; /// O5Kit window holding the dialog contents; disposed in . private O5Window? _window; /// True from the Download click until the download fails; prevents a second concurrent download. private bool _isDownloading; /// /// Archive URL for the current platform: a fixed BtbN FFmpeg-Builds autobuild (2026-05-31, FFmpeg master /// N-124714-g49a77d37be, GPL, shared libraries). Windows gets the win64 .zip; every other platform gets the /// linux64 .tar.xz (macOS never downloads, see ). /// private string DownloadUrl => Application.platform switch { RuntimePlatform.WindowsPlayer => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-win64-gpl-shared.zip", _ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-linux64-gpl-shared.tar.xz" }; /// Cancellation for the running download; created per download, cancelled by Cancel, disposed in OnDestroy. private System.Threading.CancellationTokenSource? _cts; /// Actions posted from background threads, run in order on the main Unity thread by . private readonly System.Collections.Concurrent.ConcurrentQueue _mainThreadQueue = new(); /// /// Whether UserLibs contains at least one file for each of the five FFmpeg libraries the mod uses /// (avcodec, avformat, avutil, swscale, swresample), matched by platform-specific file name patterns: /// avcodec-*.dll on Windows, libavcodec.*.dylib on macOS, libavcodec.so* elsewhere. /// Only file presence is checked, not the library version or whether it loads. /// /// false when UserLibs does not exist or any library is missing. public static bool CheckFFmpegExists() { var userLibs = MelonEnvironment.UserLibsDirectory; if (!Directory.Exists(userLibs)) return false; string[] searchPatterns = Application.platform switch { RuntimePlatform.WindowsPlayer => new[] { "avcodec-*.dll", "avformat-*.dll", "avutil-*.dll", "swscale-*.dll", "swresample-*.dll" }, RuntimePlatform.OSXPlayer => new[] { "libavcodec.*.dylib", "libavformat.*.dylib", "libavutil.*.dylib", "libswscale.*.dylib", "libswresample.*.dylib" }, _ => new[] { "libavcodec.so*", "libavformat.so*", "libavutil.so*", "libswscale.so*", "libswresample.so*" } }; foreach (var pattern in searchPatterns) { if (Directory.GetFiles(userLibs, pattern).Length == 0) { return false; } } return true; } /// /// Creates the dialog on a new GameObject kept across scene loads, unless the libraries are already present. /// Main thread only. /// public static void ShowDownloadUI() { if (CheckFFmpegExists()) return; var obj = new GameObject("FFmpegDownloadUI"); DontDestroyOnLoad(obj); obj.AddComponent(); } /// /// The open dialog, or null after it is destroyed. FirstUpdateHook waits while it is set and checks /// the libraries again after it closes. /// public static FFmpegDownloadUI? Instance { get; private set; } /// Registers ; destroys the dialog again when the libraries already exist. private void Awake() { Instance = this; if (CheckFFmpegExists()) { Destroy(gameObject); } } /// /// Builds the canvas, backdrop and window: description, button row, and the hidden progress bar and status line. /// Layout values are canvas units at the 1920x1080 reference resolution. /// private void Start() { // Set UI layer (5, Unity's built-in "UI" layer) gameObject.layer = 5; // Canvas setup var canvas = gameObject.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 32767; // Topmost // Canvas Scaler: 1920x1080 reference resolution, width and height matched equally (0.5) var scaler = gameObject.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920, 1080); scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; scaler.matchWidthOrHeight = 0.5f; gameObject.AddComponent(); var ctx = RendererUIManager.Context; // 1. Create full screen backdrop: black at 65% opacity, blocks clicks to everything below var bgPanelObj = new GameObject("Backdrop"); bgPanelObj.transform.SetParent(transform, false); bgPanelObj.layer = 5; var bgImage = bgPanelObj.AddComponent(); bgImage.color = new Color(0f, 0f, 0f, 0.65f); bgImage.raycastTarget = true; var bgRect = bgPanelObj.GetComponent(); bgRect.anchorMin = Vector2.zero; bgRect.anchorMax = Vector2.one; bgRect.offsetMin = Vector2.zero; bgRect.offsetMax = Vector2.zero; // 2. Create the O5Kit window: 580x270 units, draggable, fixed size, no close button (Cancel closes it). // The title is localized once here; the dialog does not follow later language changes. _window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions { Id = "RendererMod_FFmpegDownloadDialog", Title = LocalizationManager.Get("ui.ffmpeg_download.title"), Size = new Vector2(580, 270), Draggable = true, Resizable = false, ShowCloseButton = false, ShowOutline = true, Padding = new RectOffset(16, 16, 16, 16) }); // Vertical stack: description (takes the remaining height), buttons (40), progress bar (24), status (22), // 14 units apart var content = _window.Content; var vLayout = content.gameObject.AddComponent(); vLayout.spacing = 14f; vLayout.padding = new RectOffset(16, 16, 10, 10); vLayout.childControlWidth = true; vLayout.childControlHeight = true; vLayout.childForceExpandWidth = true; vLayout.childForceExpandHeight = false; // 3. Description text: "required" line, then either the macOS build/website lines or the download/size lines var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true); var descStrBuilder = new StringBuilder(); descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_required")); if (Application.platform == RuntimePlatform.OSXPlayer) { descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_build")); descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_open")); } else { descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_download")); descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_size")); } descText.text = descStrBuilder.ToString(); descText.alignment = TextAlignmentOptions.Center; descText.textWrappingMode = TextWrappingModes.Normal; descText.lineSpacing = 5; descText.color = new Color(0.85f, 0.85f, 0.85f, 1f); var descLE = descText.gameObject.AddComponent(); descLE.flexibleWidth = 1f; descLE.flexibleHeight = 1f; // 4. Button container: two equal-width 40-unit buttons, 10 units apart _buttonContainerObj = new GameObject("ButtonContainer"); _buttonContainerObj.transform.SetParent(content, false); _buttonContainerObj.layer = 5; var btnContainerLE = _buttonContainerObj.AddComponent(); btnContainerLE.preferredHeight = 40f; btnContainerLE.flexibleWidth = 1f; var btnLayout = _buttonContainerObj.AddComponent(); btnLayout.spacing = 10f; btnLayout.childAlignment = TextAnchor.MiddleCenter; btnLayout.childControlWidth = true; btnLayout.childControlHeight = true; btnLayout.childForceExpandWidth = true; btnLayout.childForceExpandHeight = true; // Primary button: download and install (Windows/Linux), or open the FFmpeg website and close the dialog (macOS) Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer ? StartDownloadProcess : () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }; var downloadBtn = O5Factory.Button( ctx: ctx, parent: _buttonContainerObj.transform, onClick: onDownloadClick, text: LocalizationManager.Get(Application.platform != RuntimePlatform.OSXPlayer ? "ui.ffmpeg_download.btn_download" : "ui.ffmpeg_download.btn_open_site"), id: "btn_ffmpeg_download", height: 40f ); downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright; downloadBtn.UpdateVisual(); var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent(); downloadBtnLE.flexibleWidth = 1f; downloadBtnLE.preferredHeight = 40f; var cancelBtn = O5Factory.Button( ctx: ctx, parent: _buttonContainerObj.transform, onClick: CancelProcess, text: LocalizationManager.Get("ui.ffmpeg_download.btn_cancel"), id: "btn_ffmpeg_cancel", height: 40f ); var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent(); cancelBtnLE.flexibleWidth = 1f; cancelBtnLE.preferredHeight = 40f; // 5. Progress bar container (hidden until the download starts): rounded background in the theme object color // with a rounded outline drawn over it _progressBarObj = new GameObject("ProgressBarContainer"); _progressBarObj.transform.SetParent(content, false); _progressBarObj.layer = 5; _progressBarObj.SetActive(false); var pbLE = _progressBarObj.AddComponent(); pbLE.preferredHeight = 24f; pbLE.flexibleWidth = 1f; var barBgImg = _progressBarObj.AddComponent(); barBgImg.sprite = ctx.Sprites.RoundedControl; barBgImg.type = Image.Type.Sliced; barBgImg.color = ctx.Theme.ObjectBG; var barOutlineObj = new GameObject("BarOutline"); barOutlineObj.transform.SetParent(_progressBarObj.transform, false); barOutlineObj.layer = 5; var barOutlineImg = barOutlineObj.AddComponent(); barOutlineImg.sprite = ctx.Sprites.RoundedOutline; barOutlineImg.type = Image.Type.Sliced; barOutlineImg.color = ctx.Theme.ControlOutlineIdle; barOutlineImg.raycastTarget = false; var barOutlineRect = barOutlineObj.GetComponent(); barOutlineRect.anchorMin = Vector2.zero; barOutlineRect.anchorMax = Vector2.one; barOutlineRect.offsetMin = Vector2.zero; barOutlineRect.offsetMax = Vector2.zero; // Progress bar fill image: anchored at the left edge with zero size offset, so its width is set entirely by // anchorMax.x (0 = empty, 1 = full) in UpdateProgress _progressBarFillObj = new GameObject("ProgressBarFill"); _progressBarFillObj.transform.SetParent(_progressBarObj.transform, false); _progressBarFillObj.layer = 5; var fillImg = _progressBarFillObj.AddComponent(); fillImg.sprite = ctx.Sprites.RoundedControl; fillImg.type = Image.Type.Sliced; fillImg.color = ctx.Theme.ObjectActiveBright; _fillRect = _progressBarFillObj.GetComponent(); _fillRect.anchorMin = new Vector2(0, 0); _fillRect.anchorMax = new Vector2(0, 1); _fillRect.pivot = new Vector2(0, 0.5f); _fillRect.anchoredPosition = Vector2.zero; _fillRect.sizeDelta = Vector2.zero; // Progress text: 13 pt white, centered over the bar var progressTextObj = new GameObject("ProgressBarText"); progressTextObj.transform.SetParent(_progressBarObj.transform, false); progressTextObj.layer = 5; _progressText = progressTextObj.AddComponent(); _progressText.fontSize = 13; _progressText.font = ctx.Fonts.Regular; _progressText.color = Color.white; _progressText.alignment = TextAlignmentOptions.Center; _progressText.raycastTarget = false; var progressTextRect = progressTextObj.GetComponent(); progressTextRect.anchorMin = Vector2.zero; progressTextRect.anchorMax = Vector2.one; progressTextRect.offsetMin = Vector2.zero; progressTextRect.offsetMax = Vector2.zero; // 6. Status text (hidden until the download starts): 14 pt, centered _statusTextObj = new GameObject("StatusText"); _statusTextObj.transform.SetParent(content, false); _statusTextObj.layer = 5; _statusTextObj.SetActive(false); var statusLE = _statusTextObj.AddComponent(); statusLE.preferredHeight = 22f; statusLE.flexibleWidth = 1f; _statusText = _statusTextObj.AddComponent(); _statusText.fontSize = 14; _statusText.font = ctx.Fonts.Regular; _statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f); _statusText.alignment = TextAlignmentOptions.Center; _statusText.raycastTarget = false; var statusRect = _statusTextObj.GetComponent(); statusRect.anchorMin = Vector2.zero; statusRect.anchorMax = Vector2.one; statusRect.offsetMin = Vector2.zero; statusRect.offsetMax = Vector2.zero; } /// Runs every action posted by the background download task, in posting order. private void Update() { // Execute queued actions on main Unity thread while (_mainThreadQueue.TryDequeue(out var action)) { action?.Invoke(); } } /// /// Posts an action to run on the main Unity thread during the next . Safe to call from any thread. /// Actions posted after the dialog is destroyed are never run. /// private void ExecuteOnMainThread(Action action) { _mainThreadQueue.Enqueue(action); } /// /// Download button handler (Windows/Linux): switches the dialog to progress mode and starts the background task /// that downloads the archive, extracts the libraries and closes the dialog. Ignored while a download runs. /// private void StartDownloadProcess() { if (_isDownloading) return; _isDownloading = true; // Hide buttons and show progress bar & status text _buttonContainerObj.SetActive(false); _progressBarObj.SetActive(true); _statusTextObj.SetActive(true); _cts = new System.Threading.CancellationTokenSource(); // Start download in background thread. Everything inside runs on thread-pool threads: Unity objects are only // touched through UpdateStatus/UpdateProgress/ExecuteOnMainThread. The task is not awaited; its outcome is // reported through the status text. Task.Run(async () => { string tempZipPath = ""; try { UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_starting"), new Color(0.8f, 0.8f, 0.8f)); UpdateProgress(0f, "0%"); var userLibsDir = MelonEnvironment.UserLibsDirectory; if (!Directory.Exists(userLibsDir)) { Directory.CreateDirectory(userLibsDir); } // Temporary archive path in UserLibs: ffmpeg_temp.zip (Windows, macOS) or ffmpeg_temp.tar.xz (others). // It is deleted after extraction (ExtractDllsAsync) or on cancellation. string extension = Application.platform switch { RuntimePlatform.WindowsPlayer => ".zip", RuntimePlatform.OSXPlayer => ".zip", _ => ".tar.xz" }; tempZipPath = Path.Combine(userLibsDir, "ffmpeg_temp" + extension); // percent is the downloaded fraction 0..1. Progress created on a thread-pool thread (no // SynchronizationContext) invokes this callback on thread-pool threads; it only posts UI updates. var progressReporter = new Progress(percent => { UpdateProgress(percent, $"{percent * 100f:F1}%"); UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_downloading", $"{percent * 100f:F1}"), new Color(0f, 0.8f, 1f)); }); await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token); // Extract after download complete; the bar is held at 99% while extracting UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_extracting"), new Color(0.9f, 0.9f, 0.2f)); UpdateProgress(0.99f, LocalizationManager.Get("ui.ffmpeg_download.progress_extracting")); await ExtractDllsAsync(tempZipPath, userLibsDir); // Update completion status UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_complete"), new Color(0.2f, 0.9f, 0.2f)); UpdateProgress(1f, "100%"); // Keep the "complete" message visible for 1000 ms before closing await Task.Delay(1000); // Destroy UI on main Unity thread. FirstUpdateHook then sees Instance == null and checks the libraries again. ExecuteOnMainThread(() => { Destroy(gameObject); }); } catch (OperationCanceledException) { // Clean up temporary file on cancellation. Cancellation comes from CancelProcess, which has already // destroyed the dialog, so the status update below is posted but never shown. if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath)) { try { File.Delete(tempZipPath); } catch { // ignored } } UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_cancelled"), new Color(0.9f, 0.5f, 0.2f)); } catch (Exception ex) { Melon.Logger.Error($"Error during FFmpeg download/installation: {ex}"); UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_failed", ex.Message), new Color(1f, 0.3f, 0.3f)); // Reset buttons on failure so the user can retry (Download) or close the dialog (Cancel). // The label is set to "Retry" only if a child at "DownloadButton/DownloadText" exists. The button made by // O5Factory.Button in Start is a GameObject named "O5Button", which this path does not match. ExecuteOnMainThread(() => { _isDownloading = false; _buttonContainerObj.SetActive(true); var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent(); if (downloadBtnText) { downloadBtnText.text = LocalizationManager.Get("ui.ffmpeg_download.btn_retry"); } }); } }); } /// /// Cancel button handler: requests cancellation of a running download and destroys the dialog immediately. /// An extraction already in progress is not cancellable and finishes in the background. /// private void CancelProcess() { if (_isDownloading) { _cts?.Cancel(); } Destroy(gameObject); } /// Posts a status text and color change to the main Unity thread. Callable from any thread. private void UpdateStatus(string message, Color color) { ExecuteOnMainThread(() => { if (_statusText) { _statusText.text = message; _statusText.color = color; } }); } /// Posts a progress bar update to the main Unity thread. Callable from any thread. /// Filled fraction 0..1 (not 0..100); becomes the fill's anchorMax.x. /// Text shown on the bar. private void UpdateProgress(float percent, string text) { ExecuteOnMainThread(() => { if (_fillRect) { _fillRect.anchorMax = new Vector2(percent, 1f); } if (_progressText) { _progressText.text = text; } }); } /// /// Streams the HTTP response body of to (created or /// overwritten) in 8 KiB chunks. Runs on a thread-pool thread. /// /// Archive URL. /// Output file path. /// /// Receives the downloaded fraction 0..1 after every chunk; not reported when the server sends no Content-Length. /// /// Cancels the request and the copy loop (). /// The server returned a non-success status code. private async Task DownloadFileAsync(string url, string destinationPath, IProgress progress, System.Threading.CancellationToken cancellationToken) { using var client = new HttpClient(); // Add User-Agent header to prevent blocking by servers that reject requests without one client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0"); // ResponseHeadersRead returns after the headers, so the body is streamed instead of buffered in memory using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); // Total size from Content-Length, or -1 when unknown. File stream: 8192-byte buffer, asynchronous I/O. var totalBytes = response.Content.Headers.ContentLength ?? -1L; await using var contentStream = await response.Content.ReadAsStreamAsync(); await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); var buffer = new byte[8192]; var totalRead = 0L; int read; while ((read = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) { cancellationToken.ThrowIfCancellationRequested(); await fileStream.WriteAsync(buffer, 0, read, cancellationToken); totalRead += read; if (totalBytes == -1) continue; var progressPercent = (float)totalRead / totalBytes; progress.Report(progressPercent); } } /// /// Extracts the shared libraries from the downloaded archive into , overwriting /// files of the same name, and deletes the archive afterwards (also on failure; a failed delete is logged as a /// warning). Runs on thread-pool threads and is not cancellable. /// /// Windows/macOS (.zip): every entry ending in .dll (Windows) or .dylib (macOS) is extracted flat, by its file /// name only, ignoring its folder inside the archive. /// Other platforms (.tar.xz): the archive is unpacked with the system tar command into the temporary /// folder "ffmpeg_temp_extracted" in ; every file matching *.so* in any /// subfolder is copied flat to , then the temporary folder is deleted. /// /// /// Downloaded archive path (.zip or .tar.xz despite the parameter name). /// UserLibs folder. /// tar exited with a non-zero code (message includes its standard error). private async Task ExtractDllsAsync(string zipPath, string outputDirectory) { try { if (Application.platform == RuntimePlatform.OSXPlayer || Application.platform == RuntimePlatform.WindowsPlayer) { // .zip: extract matching library entries flat into outputDirectory await Task.Run(() => { var targetExtension = Application.platform == RuntimePlatform.OSXPlayer ? ".dylib" : ".dll"; using var archive = ZipFile.OpenRead(zipPath); foreach (var entry in archive.Entries) { if (entry.FullName.EndsWith(targetExtension, StringComparison.OrdinalIgnoreCase)) { var targetPath = Path.Combine(outputDirectory, entry.Name); var dir = Path.GetDirectoryName(targetPath); if (dir != null && !Directory.Exists(dir)) { Directory.CreateDirectory(dir); } entry.ExtractToFile(targetPath, overwrite: true); } } }); } else { // Linux (or other platforms) - extract tar.xz await Task.Run(() => { var tempExtractDir = Path.Combine(outputDirectory, "ffmpeg_temp_extracted"); if (Directory.Exists(tempExtractDir)) { Directory.Delete(tempExtractDir, true); } Directory.CreateDirectory(tempExtractDir); // tar receives forward-slash paths, quoted for spaces: tar -xf "" -C "" var archiveUnixPath = zipPath.Replace('\\', '/'); var tempExtractUnixDir = tempExtractDir.Replace('\\', '/'); var startInfo = new System.Diagnostics.ProcessStartInfo { FileName = "tar", Arguments = $"-xf \"{archiveUnixPath}\" -C \"{tempExtractUnixDir}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using (var process = System.Diagnostics.Process.Start(startInfo)) { process!.WaitForExit(); if (process.ExitCode != 0) { var error = process.StandardError.ReadToEnd(); throw new Exception($"tar extraction failed (ExitCode: {process.ExitCode}): {error}"); } } // Find .so files (including versioned names such as libavcodec.so.) and copy them flat to // outputDirectory var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories); foreach (var soFile in soFiles) { var fileName = Path.GetFileName(soFile); var targetPath = Path.Combine(outputDirectory, fileName); File.Copy(soFile, targetPath, true); } // Clean up temporary directory try { Directory.Delete(tempExtractDir, true); } catch { // ignored } }); } } finally { if (File.Exists(zipPath)) { try { File.Delete(zipPath); } catch (Exception ex) { Melon.Logger.Warning($"Failed to delete temporary zip file: {ex.Message}"); } } } } /// /// Disposes the window and the cancellation source and clears . A download that was not /// cancelled keeps running in the background; its UI updates are posted but no longer drained. /// private void OnDestroy() { _window?.Dispose(); _window = null; _cts?.Dispose(); if (Instance == this) { Instance = null; } } }