using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using MelonLoader; namespace ADOFAIRenderer.Util; [SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "UnusedMember.Local")] 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; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var ownerHandle = GetGameWindowHandle(); var thread = new Thread(() => { 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}"); } } } #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 active = GetActiveWindow(); if (active != IntPtr.Zero) return active; var currentPid = (uint)Process.GetCurrentProcess().Id; var fg = GetForegroundWindow(); if (fg != IntPtr.Zero) { GetWindowThreadProcessId(fg, out var fgPid); if (fgPid == currentPid) { return fg; } } var mainHandle = Process.GetCurrentProcess().MainWindowHandle; if (mainHandle != IntPtr.Zero) { return mainHandle; } } catch { // Ignore failure to detect window handle } return IntPtr.Zero; } private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle) { try { return OpenWindowsFolderBrowserModern(initialDirectory, ownerHandle); } catch (Exception ex) { 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) { // 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 var dialog); if (createHr != 0) { // 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) { 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); 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, _, 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 { 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 { FileName = "osascript", CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, WindowStyle = ProcessWindowStyle.Hidden }; startInfo.ArgumentList.Add("-e"); startInfo.ArgumentList.Add(script); using var process = Process.Start(startInfo); if (process == null) return null; var output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(); return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null; } 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 { var startInfo = new ProcessStartInfo { FileName = "zenity", CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, WindowStyle = ProcessWindowStyle.Hidden }; startInfo.ArgumentList.Add("--file-selection"); startInfo.ArgumentList.Add("--directory"); startInfo.ArgumentList.Add("--title=Select Output Directory"); if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory)) { startInfo.ArgumentList.Add($"--filename={initialDirectory}"); } try { using var process = Process.Start(startInfo); if (process != null) { 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 } 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 (Exception ex) { MelonLogger.Warning($"[FolderDialogUtil] Linux folder dialog error: {ex.Message}"); return null; } } #endregion }