From 5929b5ddbe0c955f3381139aa2719ad3322589e88ca1ecc1faba4f1a061349b3 Mon Sep 17 00:00:00 2001 From: copecone Date: Tue, 6 Oct 2026 15:06:05 +0900 Subject: [PATCH] Fix Folder Selection Browser --- UI/Window/RenderConfigWindow.cs | 7 +- Util/FolderDialogUtil.cs | 586 ++++++++++++++++++++++++++++---- 2 files changed, 525 insertions(+), 68 deletions(-) diff --git a/UI/Window/RenderConfigWindow.cs b/UI/Window/RenderConfigWindow.cs index 5eca340..50f92fd 100644 --- a/UI/Window/RenderConfigWindow.cs +++ b/UI/Window/RenderConfigWindow.cs @@ -8,6 +8,7 @@ using ADOFAIRenderer.Data; using ADOFAIRenderer.Localization; using ADOFAIRenderer.Util; using ADOFAIRenderer.Util.FFmpeg; +using HarmonyLib; using MelonLoader; using O5Kit.Control; using O5Kit.Core; @@ -307,12 +308,11 @@ internal static class RenderConfigWindow c => config.AudioConfig.AudioChannels = c ); - var sampleRates = new[] { 44100, 48000, 96000 }; _sampleRateDropdown = CreateDropdownField( ctx, CreateFieldSlot(audioRight), LocalizationManager.Get("ui.render_config.sample_rate"), 48000, config.AudioConfig.AudioSampleRate, - sampleRates, r => $"{r/1000.0} kHz", + CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz", "dd_sample_rate", r => config.AudioConfig.AudioSampleRate = r ); @@ -359,6 +359,7 @@ internal static class RenderConfigWindow : _outputDirInput.Value; FolderDialogUtil.OpenFolderBrowser(initial, selected => { + Melon.Logger.Msg("Output Directory Selected: " + selected); if (!string.IsNullOrWhiteSpace(selected)) { _outputDirInput.Set(selected); @@ -435,6 +436,8 @@ internal static class RenderConfigWindow val => config.AdvancedConfig.NonTile = val, generateLabel: false ); + + // TODO: Fire Planet, Ice Planet, Wind Planet Color Settings (Each Optional) // 8. Go Render Button O5Factory.Button( diff --git a/Util/FolderDialogUtil.cs b/Util/FolderDialogUtil.cs index a3253a5..5bf1797 100644 --- a/Util/FolderDialogUtil.cs +++ b/Util/FolderDialogUtil.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using MelonLoader; @@ -10,95 +11,505 @@ namespace ADOFAIRenderer.Util; internal static class FolderDialogUtil { + private static int _isDialogOpen; + public static void OpenFolderBrowser(string initialDirectory, Action onSelected) { + if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0) + { + MelonLogger.Msg("[FolderDialogUtil] Folder dialog is already open."); + return; + } + var syncContext = SynchronizationContext.Current; - Task.Run(() => + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - string? selected = null; - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - selected = OpenWindowsFolderBrowser(initialDirectory); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - selected = OpenMacFolderBrowser(initialDirectory); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - selected = OpenLinuxFolderBrowser(initialDirectory); - } - } - catch (Exception ex) - { - MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}"); - } + var ownerHandle = GetGameWindowHandle(); - if (syncContext != null) + var thread = new Thread(() => { - syncContext.Post(_ => onSelected(selected), null); - } - else + string? selected = null; + int oleHr = -1; + try + { + oleHr = OleInitialize(IntPtr.Zero); + selected = OpenWindowsFolderBrowser(initialDirectory, ownerHandle); + } + catch (Exception ex) + { + MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}"); + } + finally + { + if (oleHr >= 0) + { + try + { + OleUninitialize(); + } + catch + { + // Ignore uninitialize error + } + } + Interlocked.Exchange(ref _isDialogOpen, 0); + } + + DispatchResult(syncContext, onSelected, selected); + }) + { + IsBackground = true, + Name = "FolderDialog-Win" + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + else + { + Task.Run(() => + { + string? selected = null; + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + selected = OpenMacFolderBrowser(initialDirectory); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + selected = OpenLinuxFolderBrowser(initialDirectory); + } + } + catch (Exception ex) + { + MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}"); + } + finally + { + Interlocked.Exchange(ref _isDialogOpen, 0); + } + + DispatchResult(syncContext, onSelected, selected); + }); + } + } + + private static void DispatchResult(SynchronizationContext? syncContext, Action onSelected, string? selected) + { + if (syncContext != null) + { + syncContext.Post(_ => + { + try + { + onSelected(selected); + } + catch (Exception ex) + { + MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}"); + } + }, null); + } + else + { + try { onSelected(selected); } - }); + catch (Exception ex) + { + MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}"); + } + } } - private static string? OpenWindowsFolderBrowser(string initialDirectory) + #region Windows Implementation + + private static readonly Guid CLSID_FileOpenDialog = new("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7"); + private static readonly Guid IID_IFileOpenDialog = new("d57c7288-d4ad-4768-be02-9d969532d960"); + private static readonly Guid IID_IShellItem = new("43826D1E-E718-42EE-BC55-A1E261C37BFE"); + private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7); + + [DllImport("user32.dll")] + private static extern IntPtr GetActiveWindow(); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + private static IntPtr GetGameWindowHandle() { try { - var escapedDir = initialDirectory.Replace("'", "''"); - var script = $@" -[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null -$f = New-Object System.Windows.Forms.FolderBrowserDialog -$f.Description = 'Select Output Directory' -if ([System.IO.Directory]::Exists('{escapedDir}')) {{ - $f.SelectedPath = '{escapedDir}' -}} -$f.ShowNewFolderButton = $true -if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ - [Console]::Write($f.SelectedPath) -}}"; + var active = GetActiveWindow(); + if (active != IntPtr.Zero) return active; - var bytes = System.Text.Encoding.Unicode.GetBytes(script); - var base64 = Convert.ToBase64String(bytes); - - var startInfo = new ProcessStartInfo + var currentPid = (uint)Process.GetCurrentProcess().Id; + var fg = GetForegroundWindow(); + if (fg != IntPtr.Zero) { - FileName = "powershell.exe", - Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}", - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardOutput = true, - WindowStyle = ProcessWindowStyle.Hidden - }; + GetWindowThreadProcessId(fg, out var fgPid); + if (fgPid == currentPid) + { + return fg; + } + } - using var process = Process.Start(startInfo); - if (process == null) return null; + var mainHandle = Process.GetCurrentProcess().MainWindowHandle; + if (mainHandle != IntPtr.Zero) + { + return mainHandle; + } + } + catch + { + // Ignore failure to detect window handle + } - var output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); + return IntPtr.Zero; + } - return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null; + private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle) + { + try + { + return OpenWindowsFolderBrowserModern(initialDirectory, ownerHandle); } catch (Exception ex) { - MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}"); - return null; + MelonLogger.Warning($"[FolderDialogUtil] Modern folder dialog failed ({ex.Message}), falling back to legacy dialog."); + return OpenWindowsFolderBrowserLegacy(initialDirectory, ownerHandle); } } + private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle) + { + IFileOpenDialog? dialog = null; + + // 1. Try native CoCreateInstance first (works on Mono and .NET without class registration) + var clsid = CLSID_FileOpenDialog; + var iid = IID_IFileOpenDialog; + int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out dialog); + + if (createHr != 0 || dialog == null) + { + // 2. Fallback to Activator via Type.GetTypeFromCLSID + var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog); + if (comType != null) + { + var instance = Activator.CreateInstance(comType); + dialog = instance as IFileOpenDialog; + } + } + + if (dialog == null) + { + throw new COMException("Failed to instantiate IFileOpenDialog via COM.", createHr); + } + + try + { + dialog.SetOptions(FOS.FOS_PICKFOLDERS | FOS.FOS_FORCEFILESYSTEM | FOS.FOS_PATHMUSTEXIST); + dialog.SetTitle("Select Output Directory"); + + if (!string.IsNullOrWhiteSpace(initialDirectory)) + { + try + { + var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/'); + if (Directory.Exists(normalized)) + { + var shellItemGuid = IID_IShellItem; + int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem); + if (itemHr == 0 && initialItem != null) + { + try + { + dialog.SetFolder(initialItem); + dialog.SetDefaultFolder(initialItem); + } + finally + { + Marshal.ReleaseComObject(initialItem); + } + } + } + } + catch + { + // Ignore failure to set initial folder + } + } + + int hr = dialog.Show(ownerHandle); + if (hr != 0 && hr != ERROR_CANCELLED_HR && ownerHandle != IntPtr.Zero) + { + // If Show failed with ownerHandle (e.g. cross-thread or invalid handle), retry without owner + MelonLogger.Msg($"[FolderDialogUtil] dialog.Show(ownerHandle) returned 0x{hr:X8}, retrying with IntPtr.Zero."); + hr = dialog.Show(IntPtr.Zero); + } + + if (hr == ERROR_CANCELLED_HR) + { + return null; + } + + if (hr != 0) + { + throw new COMException("IFileOpenDialog.Show failed", hr); + } + + dialog.GetResult(out var resultItem); + if (resultItem == null) return null; + + try + { + if (resultItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var pszPath) == 0 && pszPath != IntPtr.Zero) + { + try + { + var path = Marshal.PtrToStringUni(pszPath); + return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null; + } + finally + { + Marshal.FreeCoTaskMem(pszPath); + } + } + } + finally + { + Marshal.ReleaseComObject(resultItem); + } + + return null; + } + finally + { + Marshal.ReleaseComObject(dialog); + } + } + + private static string? OpenWindowsFolderBrowserLegacy(string initialDirectory, IntPtr ownerHandle) + { + IntPtr pszDisplayName = Marshal.AllocHGlobal(260 * sizeof(char)); + IntPtr initialDirPtr = IntPtr.Zero; + BrowseCallbackProc? callback = null; + IntPtr pidl = IntPtr.Zero; + + try + { + var bi = new BROWSEINFO + { + hwndOwner = ownerHandle, + pidlRoot = IntPtr.Zero, + pszDisplayName = pszDisplayName, + lpszTitle = "Select Output Directory", + ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE | BIF_EDITBOX + }; + + if (!string.IsNullOrWhiteSpace(initialDirectory)) + { + try + { + var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/'); + if (Directory.Exists(normalized)) + { + initialDirPtr = Marshal.StringToHGlobalUni(normalized); + callback = (hwnd, uMsg, lParam, lpData) => + { + if (uMsg == BFFM_INITIALIZED && lpData != IntPtr.Zero) + { + SendMessage(hwnd, BFFM_SETSELECTIONW, (IntPtr)1, lpData); + } + return 0; + }; + bi.lpfn = callback; + bi.lParam = initialDirPtr; + } + } + catch + { + // Ignore failure to prepare initial directory + } + } + + pidl = SHBrowseForFolderW(ref bi); + if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero) + { + // If it returned null with ownerHandle, check if retry with IntPtr.Zero is appropriate + // But note: cancelling also returns Zero. We do not redundantly re-prompt if cancelled. + } + + if (pidl != IntPtr.Zero) + { + var sb = new StringBuilder(260); + if (SHGetPathFromIDListW(pidl, sb)) + { + var path = sb.ToString(); + return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null; + } + } + + return null; + } + finally + { + if (pidl != IntPtr.Zero) + { + Marshal.FreeCoTaskMem(pidl); + } + if (initialDirPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(initialDirPtr); + } + if (pszDisplayName != IntPtr.Zero) + { + Marshal.FreeHGlobal(pszDisplayName); + } + GC.KeepAlive(callback); + } + } + + #region COM & Win32 Interop Definitions + + [Flags] + private enum FOS : uint + { + FOS_NOCHANGEDIR = 0x00000008, + FOS_PICKFOLDERS = 0x00000020, + FOS_FORCEFILESYSTEM = 0x00000040, + FOS_PATHMUSTEXIST = 0x00000800, + FOS_FILEMUSTEXIST = 0x00001000, + } + + private enum SIGDN : uint + { + SIGDN_FILESYSPATH = 0x80058000 + } + + [ComImport] + [Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItem + { + void BindToHandler(IntPtr pbc, [In] ref Guid bhid, [In] ref Guid riid, out IntPtr ppv); + void GetParent(out IShellItem ppsi); + [PreserveSig] + int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName); + void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs); + void Compare(IShellItem psi, uint hint, out int piOrder); + } + + [ComImport] + [Guid("d57c7288-d4ad-4768-be02-9d969532d960")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IFileOpenDialog + { + [PreserveSig] + int Show(IntPtr parent); + void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec); + void SetFileTypeIndex(uint iFileType); + void GetFileTypeIndex(out uint piFileType); + void Advise(IntPtr pfde, out uint pdwCookie); + void Unadvise(uint dwCookie); + void SetOptions(FOS fos); + void GetOptions(out FOS pfos); + void SetDefaultFolder(IShellItem psi); + void SetFolder(IShellItem psi); + void GetFolder(out IShellItem ppsi); + void GetCurrentSelection(out IShellItem ppsi); + void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName); + void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle); + void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText); + void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel); + void GetResult(out IShellItem ppsi); + void AddPlace(IShellItem psi, int fdap); + void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension); + void Close(int hr); + void SetClientGuid(ref Guid guid); + void ClearClientData(); + void SetFilter(IntPtr pFilter); + void GetResults(out IntPtr ppenum); + void GetSelectedItems(out IntPtr ppsai); + } + + [DllImport("ole32.dll", ExactSpelling = true)] + private static extern int OleInitialize(IntPtr pvReserved); + + [DllImport("ole32.dll", ExactSpelling = true)] + private static extern void OleUninitialize(); + + [DllImport("ole32.dll", ExactSpelling = true)] + private static extern int CoCreateInstance( + [In] ref Guid rclsid, + IntPtr pUnkOuter, + uint dwClsContext, + [In] ref Guid riid, + [Out, MarshalAs(UnmanagedType.Interface)] out IFileOpenDialog ppv); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)] + private static extern int SHCreateItemFromParsingName( + [MarshalAs(UnmanagedType.LPWStr)] string pszPath, + IntPtr pbc, + [In] ref Guid riid, + [Out, MarshalAs(UnmanagedType.Interface)] out IShellItem ppv); + + private const uint BIF_RETURNONLYFSDIRS = 0x0001; + private const uint BIF_NEWDIALOGSTYLE = 0x0040; + private const uint BIF_EDITBOX = 0x0010; + private const uint BFFM_INITIALIZED = 1; + private const uint BFFM_SETSELECTIONW = 0x0400 + 103; + + private delegate int BrowseCallbackProc(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct BROWSEINFO + { + public IntPtr hwndOwner; + public IntPtr pidlRoot; + public IntPtr pszDisplayName; + public string lpszTitle; + public uint ulFlags; + public BrowseCallbackProc? lpfn; + public IntPtr lParam; + public int iImage; + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + #endregion + + #endregion + + #region macOS Implementation + private static string? OpenMacFolderBrowser(string initialDirectory) { try { - var escaped = initialDirectory.Replace("\"", "\\\""); - var script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location \"{escaped}\")"; + string script; + if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory)) + { + var escaped = initialDirectory.Replace("\\", "\\\\").Replace("\"", "\\\""); + script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location (POSIX file \"{escaped}\"))"; + } + else + { + script = "POSIX path of (choose folder with prompt \"Select Output Directory\")"; + } var startInfo = new ProcessStartInfo { @@ -119,12 +530,17 @@ if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null; } - catch + catch (Exception ex) { + MelonLogger.Warning($"[FolderDialogUtil] Mac folder dialog error: {ex.Message}"); return null; } } + #endregion + + #region Linux Implementation + private static string? OpenLinuxFolderBrowser(string initialDirectory) { try @@ -145,17 +561,55 @@ if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ startInfo.ArgumentList.Add($"--filename={initialDirectory}"); } - using var process = Process.Start(startInfo); - if (process == null) return null; + try + { + using var process = Process.Start(startInfo); + if (process != null) + { + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); - var output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); + if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output)) + { + return output; + } + } + } + catch + { + // Fallback to kdialog if zenity fails to start + } - return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null; + var kdialogInfo = new ProcessStartInfo + { + FileName = "kdialog", + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + kdialogInfo.ArgumentList.Add("--getexistingdirectory"); + if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory)) + { + kdialogInfo.ArgumentList.Add(initialDirectory); + } + kdialogInfo.ArgumentList.Add("--title"); + kdialogInfo.ArgumentList.Add("Select Output Directory"); + + using var kdialogProcess = Process.Start(kdialogInfo); + if (kdialogProcess == null) return null; + + var koutput = kdialogProcess.StandardOutput.ReadToEnd().Trim(); + kdialogProcess.WaitForExit(); + + return !string.IsNullOrEmpty(koutput) && Directory.Exists(koutput) ? koutput : null; } - catch + catch (Exception ex) { + MelonLogger.Warning($"[FolderDialogUtil] Linux folder dialog error: {ex.Message}"); return null; } } + + #endregion }