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.OS; /// /// Native "select folder" dialog used to choose the render output directory. /// /// - Windows: the shell's IFileOpenDialog (COM, FOS_PICKFOLDERS) on a dedicated STA thread owned by the /// game window; when it cannot be created or shown, the legacy SHBrowseForFolderW dialog. /// /// - macOS: osascript running AppleScript choose folder. /// /// - Linux: zenity --file-selection --directory; when zenity does not return an existing directory (not installed, /// cancelled or failed), kdialog --getexistingdirectory. /// /// /// The dialog runs off the Unity main thread so the game keeps rendering frames while it is open. Only one dialog can be open /// at a time. Failures are reported to the callback as null (same as cancel); exceptions are logged as warnings, except a /// zenity start failure, which silently moves on to kdialog. /// /// [SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "UnusedMember.Local")] internal static class FolderDialogUtil { // 1 while a dialog is open, 0 otherwise. Set with Interlocked.CompareExchange in OpenFolderBrowser and reset by the // dialog worker before it dispatches the result. private static int _isDialogOpen; /// /// Opens the folder selection dialog asynchronously and returns immediately. /// /// When a dialog is already open, logs a message and returns without opening another one; /// is not called in that case. Otherwise the dialog runs on a background thread (Windows: new STA thread /// "FolderDialog-Win"; macOS/Linux: thread pool task) and receives the result. /// /// /// Callback thread: when the caller has a (the Unity main thread has one), the callback /// is posted to it and runs on the caller's thread; otherwise it runs on the dialog thread. Exceptions thrown by the /// callback are caught and logged. /// /// On platforms other than Windows, macOS and Linux the callback receives null. /// /// Folder shown first; ignored when empty or not an existing directory. /// /// Receives the absolute path of the selected existing directory, or null when the user cancelled or the dialog failed. /// 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; } // Captured on the calling thread so the result can be posted back to it. var syncContext = SynchronizationContext.Current; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Resolved on the calling thread: GetActiveWindow only returns a window attached to the calling thread's // message queue. var ownerHandle = GetGameWindowHandle(); // Both dialogs run on a single-threaded apartment (STA) thread with OLE initialized; SHBrowseForFolder with // BIF_NEWDIALOGSTYLE fails in a multithreaded apartment. // Reference: https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-browseinfow 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 { // OleInitialize returns S_OK or S_FALSE (already initialized) on success; each successful call is // balanced by one OleUninitialize. 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); }); } } /// /// Delivers the dialog result: posts to when it is not null, /// otherwise invokes it on the current (dialog) thread. Callback exceptions are caught and logged. /// /// Synchronization context of the thread that opened the dialog, or null. /// Result callback. /// Selected directory path, or null. 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 // COM class ID of the shell's common file dialog (FileOpenDialog) and the interface IDs of IFileOpenDialog and // IShellItem (shobjidl_core.h). 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"); // HRESULT_FROM_WIN32(ERROR_CANCELLED) = 0x800704C7: IModalWindow::Show result when the user cancels the dialog. // Reference: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-imodalwindow-show private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7); // Active window attached to the calling thread's message queue, or NULL. [DllImport("user32.dll")] private static extern IntPtr GetActiveWindow(); // Foreground window of the desktop (any process), or NULL. [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); // Thread ID that created the window; the process ID is returned through lpdwProcessId. [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); /// /// Finds the game window to own the dialog, in order: the calling thread's active window, the foreground window when it /// belongs to this process, then . /// /// Window handle, or when none is found or a call fails (the dialog is then unowned). 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; } /// /// Shows the IFileOpenDialog folder picker; when it throws (cannot be created, or Show fails with an error other than /// cancel), logs a warning and shows the legacy SHBrowseForFolder dialog. Runs on the STA dialog thread. /// /// Folder shown first, or empty. /// Owner window, or . /// Selected existing directory, or null on cancel. 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); } } /// /// Shows the shell common file dialog in folder mode. /// /// Options: FOS_PICKFOLDERS (choose folders), FOS_FORCEFILESYSTEM (file system items only) and /// FOS_PATHMUSTEXIST. The initial folder is set with SetFolder and SetDefaultFolder when it exists. /// When Show fails with an owner window (error other than cancel), it is retried once without an owner. /// The result item is read as a file system path (SIGDN_FILESYSPATH); the returned string is freed with /// and the COM objects are released. /// /// /// Folder shown first, or empty. /// Owner window, or . /// Selected existing directory; null on cancel or when the result has no file system path. /// The dialog cannot be created, or Show fails with an error other than cancel. private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle) { // 1. Create the dialog with ole32 CoCreateInstance (in-process server), marshalled directly to IFileOpenDialog. 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. On failure, create it through the runtime's COM interop (Type.GetTypeFromCLSID + Activator.CreateInstance). // createHr keeps the CoCreateInstance error for the exception below. 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)) { // The dialog takes folders as IShellItem; SHCreateItemFromParsingName creates one from the path. 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); } } /// /// Shows the legacy shell folder browser (SHBrowseForFolderW) with the resizable dialog style, an edit box, and /// file system folders only. The initial folder is selected from the dialog callback (BFFM_INITIALIZED -> /// BFFM_SETSELECTIONW with the path string). Cancel and failure both return null. Native buffers and the returned /// item ID list are freed before returning. /// /// Folder selected first, or empty. /// Owner window, or . /// Selected existing directory, or null. private static string? OpenWindowsFolderBrowserLegacy(string initialDirectory, IntPtr ownerHandle) { // Display name buffer: MAX_PATH (260) UTF-16 characters, the size SHBrowseForFolder assumes. // Reference: https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-browseinfow 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)) { // lpData = bi.lParam = the UTF-16 path; wParam 1 (TRUE) tells BFFM_SETSELECTION it is a string. // The delegate is kept alive until the dialog returns (GC.KeepAlive in finally). // Reference: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/bb762598(v=vs.85) 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) { // SHBrowseForFolderW returns NULL both on cancel and on failure, so no retry without the owner is made. } if (pidl != IntPtr.Zero) { // SHGetPathFromIDListW writes up to MAX_PATH (260) characters. 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 /// /// FILEOPENDIALOGOPTIONS flags passed to IFileDialog::SetOptions (subset; values from shobjidl_core.h). /// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/ne-shobjidl_core-_fileopendialogoptions /// [Flags] private enum FOS : uint { FOS_NOCHANGEDIR = 0x00000008, FOS_PICKFOLDERS = 0x00000020, FOS_FORCEFILESYSTEM = 0x00000040, FOS_PATHMUSTEXIST = 0x00000800, FOS_FILEMUSTEXIST = 0x00001000, } /// /// Display name form requested from IShellItem::GetDisplayName; SIGDN_FILESYSPATH is the item's file system /// path (the call fails for items without one). /// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/ne-shobjidl_core-sigdn /// private enum SIGDN : uint { SIGDN_FILESYSPATH = 0x80058000 } /// /// COM interop declaration of the shell IShellItem interface (vtable order of shobjidl_core.h). Only /// GetDisplayName is called; it keeps its HRESULT return (PreserveSig). /// [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); } /// /// COM interop declaration of IFileOpenDialog: the methods of IModalWindow (Show), IFileDialog /// and IFileOpenDialog in vtable order. Show keeps its HRESULT return (PreserveSig); the other methods /// throw on a failure HRESULT. /// [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); } // Initializes COM as a single-threaded apartment plus OLE on the calling thread; returns an HRESULT (S_OK / S_FALSE on success). [DllImport("ole32.dll", ExactSpelling = true)] private static extern int OleInitialize(IntPtr pvReserved); // Balances one successful OleInitialize on the same thread. [DllImport("ole32.dll", ExactSpelling = true)] private static extern void OleUninitialize(); // Creates a COM object of class rclsid and returns interface riid; declared here with the IFileOpenDialog return type. [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); // Creates an IShellItem from a parsing name (file system path); returns an HRESULT (0 = S_OK). [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); // BROWSEINFO.ulFlags: file system directories only, resizable new-style dialog, edit box for typing a name. // Reference: https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-browseinfow private const uint BIF_RETURNONLYFSDIRS = 0x0001; private const uint BIF_NEWDIALOGSTYLE = 0x0040; private const uint BIF_EDITBOX = 0x0010; // Browse dialog messages (shlobj_core.h): BFFM_INITIALIZED is sent to the callback when the dialog has finished // initializing; BFFM_SETSELECTIONW (WM_USER + 103, Unicode) is sent to the dialog to select a folder. The reference // describes the messages; the numeric values are the shlobj_core.h definitions. // Reference:https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/bb762598(v=vs.85) private const uint BFFM_INITIALIZED = 1; private const uint BFFM_SETSELECTIONW = 0x0400 + 103; // BFFCALLBACK: called by the browse dialog for dialog events; returns 0 except for BFFM_VALIDATEFAILED. private delegate int BrowseCallbackProc(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData); // Unicode BROWSEINFOW structure passed to SHBrowseForFolderW (field order and types of shlobj_core.h). [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; } // Shows the modal browse dialog; returns the selected item's ID list (freed with CoTaskMemFree), or NULL on cancel or failure. [DllImport("shell32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi); // Converts an item ID list to a file system path; false when the item is not part of the file system. [DllImport("shell32.dll", CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath); // Sends a window message and waits for it to be processed (used from the browse dialog callback). [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); #endregion #endregion #region macOS Implementation /// /// Runs osascript -e with the AppleScript POSIX path of (choose folder with prompt ... [default location ...]) /// and blocks until the process exits. The initial directory is passed as a POSIX file in an AppleScript string literal /// (backslashes and double quotes escaped). On cancel osascript prints nothing to standard output. /// /// Folder shown first; used only when it is an existing directory. /// /// The trimmed standard output when it is an existing directory; null on cancel, when the process cannot be started, or /// on an exception (logged as a warning). /// private static string? OpenMacFolderBrowser(string initialDirectory) { try { // POSIX path of (...) converts the chosen folder alias to a slash-separated path. 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 /// /// Runs zenity --file-selection --directory --title=... [--filename=initialDirectory] and blocks until it exits. /// When zenity cannot be started, exits with a non-zero code (cancel or error) or prints a path that is not an existing /// directory, runs kdialog --getexistingdirectory [initialDirectory] --title ... instead. Both commands print the /// selected path to standard output. /// /// Folder shown first; used only when it is an existing directory. /// /// The selected existing directory; null when both dialogs return nothing, when kdialog cannot be started, or on an /// exception (logged as a warning). /// 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 }