Document OS Utilities

This commit is contained in:
copecone committed 2026-10-15 16:17:13 +09:00
1 parent 00407c1320
commit 39be8343c3
3 files changed
+322 -12

No files matched your search

+160 -4
View File
@@ -10,12 +10,49 @@ using MelonLoader;
namespace ADOFAIRenderer.Util.OS; namespace ADOFAIRenderer.Util.OS;
/// <summary>
/// Native "select folder" dialog used to choose the render output directory.
/// <para>
/// - <b>Windows</b>: the shell's <c>IFileOpenDialog</c> (COM, <c>FOS_PICKFOLDERS</c>) on a dedicated STA thread owned by the
/// game window; when it cannot be created or shown, the legacy <c>SHBrowseForFolderW</c> dialog.
/// </para>
/// <para>- <b>macOS</b>: <c>osascript</c> running AppleScript <c>choose folder</c>.</para>
/// <para>
/// - <b>Linux</b>: <c>zenity --file-selection --directory</c>; when zenity does not return an existing directory (not installed,
/// cancelled or failed), <c>kdialog --getexistingdirectory</c>.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")] [SuppressMessage("ReSharper", "UnusedMember.Local")]
internal static class FolderDialogUtil 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; private static int _isDialogOpen;
/// <summary>
/// Opens the folder selection dialog asynchronously and returns immediately.
/// <para>
/// When a dialog is already open, logs a message and returns without opening another one; <paramref name="onSelected"/>
/// 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 <paramref name="onSelected"/> receives the result.
/// </para>
/// <para>
/// Callback thread: when the caller has a <see cref="SynchronizationContext"/> (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.
/// </para>
/// <para>On platforms other than Windows, macOS and Linux the callback receives null.</para>
/// </summary>
/// <param name="initialDirectory">Folder shown first; ignored when empty or not an existing directory.</param>
/// <param name="onSelected">
/// Receives the absolute path of the selected existing directory, or null when the user cancelled or the dialog failed.
/// </param>
public static void OpenFolderBrowser(string initialDirectory, Action<string?> onSelected) public static void OpenFolderBrowser(string initialDirectory, Action<string?> onSelected)
{ {
if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0) if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0)
@@ -24,12 +61,18 @@ internal static class FolderDialogUtil
return; return;
} }
// Captured on the calling thread so the result can be posted back to it.
var syncContext = SynchronizationContext.Current; var syncContext = SynchronizationContext.Current;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 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(); 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(() => var thread = new Thread(() =>
{ {
string? selected = null; string? selected = null;
@@ -45,6 +88,8 @@ internal static class FolderDialogUtil
} }
finally finally
{ {
// OleInitialize returns S_OK or S_FALSE (already initialized) on success; each successful call is
// balanced by one OleUninitialize.
if (oleHr >= 0) if (oleHr >= 0)
{ {
try try
@@ -98,6 +143,13 @@ internal static class FolderDialogUtil
} }
} }
/// <summary>
/// Delivers the dialog result: posts <paramref name="onSelected"/> to <paramref name="syncContext"/> when it is not null,
/// otherwise invokes it on the current (dialog) thread. Callback exceptions are caught and logged.
/// </summary>
/// <param name="syncContext">Synchronization context of the thread that opened the dialog, or null.</param>
/// <param name="onSelected">Result callback.</param>
/// <param name="selected">Selected directory path, or null.</param>
private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected) private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected)
{ {
if (syncContext != null) if (syncContext != null)
@@ -129,20 +181,32 @@ internal static class FolderDialogUtil
#region Windows Implementation #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 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_IFileOpenDialog = new("d57c7288-d4ad-4768-be02-9d969532d960");
private static readonly Guid IID_IShellItem = new("43826D1E-E718-42EE-BC55-A1E261C37BFE"); 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); private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7);
// Active window attached to the calling thread's message queue, or NULL.
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern IntPtr GetActiveWindow(); private static extern IntPtr GetActiveWindow();
// Foreground window of the desktop (any process), or NULL.
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow(); private static extern IntPtr GetForegroundWindow();
// Thread ID that created the window; the process ID is returned through lpdwProcessId.
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
/// <summary>
/// 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 <see cref="Process.MainWindowHandle"/>.
/// </summary>
/// <returns>Window handle, or <see cref="IntPtr.Zero"/> when none is found or a call fails (the dialog is then unowned).</returns>
private static IntPtr GetGameWindowHandle() private static IntPtr GetGameWindowHandle()
{ {
try try
@@ -175,6 +239,13 @@ internal static class FolderDialogUtil
return IntPtr.Zero; return IntPtr.Zero;
} }
/// <summary>
/// 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.
/// </summary>
/// <param name="initialDirectory">Folder shown first, or empty.</param>
/// <param name="ownerHandle">Owner window, or <see cref="IntPtr.Zero"/>.</param>
/// <returns>Selected existing directory, or null on cancel.</returns>
private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle) private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle)
{ {
try try
@@ -188,16 +259,31 @@ internal static class FolderDialogUtil
} }
} }
/// <summary>
/// Shows the shell common file dialog in folder mode.
/// <para>
/// Options: <c>FOS_PICKFOLDERS</c> (choose folders), <c>FOS_FORCEFILESYSTEM</c> (file system items only) and
/// <c>FOS_PATHMUSTEXIST</c>. The initial folder is set with <c>SetFolder</c> and <c>SetDefaultFolder</c> when it exists.
/// When <c>Show</c> 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 (<c>SIGDN_FILESYSPATH</c>); the returned string is freed with
/// <see cref="Marshal.FreeCoTaskMem"/> and the COM objects are released.
/// </para>
/// </summary>
/// <param name="initialDirectory">Folder shown first, or empty.</param>
/// <param name="ownerHandle">Owner window, or <see cref="IntPtr.Zero"/>.</param>
/// <returns>Selected existing directory; null on cancel or when the result has no file system path.</returns>
/// <exception cref="COMException">The dialog cannot be created, or Show fails with an error other than cancel.</exception>
private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle) private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle)
{ {
// 1. Try native CoCreateInstance first (works on Mono and .NET without class registration) // 1. Create the dialog with ole32 CoCreateInstance (in-process server), marshalled directly to IFileOpenDialog.
var clsid = CLSID_FileOpenDialog; var clsid = CLSID_FileOpenDialog;
var iid = IID_IFileOpenDialog; var iid = IID_IFileOpenDialog;
int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out var dialog); int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out var dialog);
if (createHr != 0) if (createHr != 0)
{ {
// 2. Fallback to Activator via Type.GetTypeFromCLSID // 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); var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog);
if (comType != null) if (comType != null)
{ {
@@ -223,6 +309,7 @@ internal static class FolderDialogUtil
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/'); var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized)) if (Directory.Exists(normalized))
{ {
// The dialog takes folders as IShellItem; SHCreateItemFromParsingName creates one from the path.
var shellItemGuid = IID_IShellItem; var shellItemGuid = IID_IShellItem;
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem); int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
if (itemHr == 0) if (itemHr == 0)
@@ -293,8 +380,19 @@ internal static class FolderDialogUtil
} }
} }
/// <summary>
/// Shows the legacy shell folder browser (<c>SHBrowseForFolderW</c>) with the resizable dialog style, an edit box, and
/// file system folders only. The initial folder is selected from the dialog callback (<c>BFFM_INITIALIZED</c> ->
/// <c>BFFM_SETSELECTIONW</c> with the path string). Cancel and failure both return null. Native buffers and the returned
/// item ID list are freed before returning.
/// </summary>
/// <param name="initialDirectory">Folder selected first, or empty.</param>
/// <param name="ownerHandle">Owner window, or <see cref="IntPtr.Zero"/>.</param>
/// <returns>Selected existing directory, or null.</returns>
private static string? OpenWindowsFolderBrowserLegacy(string initialDirectory, IntPtr ownerHandle) 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 pszDisplayName = Marshal.AllocHGlobal(260 * sizeof(char));
IntPtr initialDirPtr = IntPtr.Zero; IntPtr initialDirPtr = IntPtr.Zero;
BrowseCallbackProc? callback = null; BrowseCallbackProc? callback = null;
@@ -318,6 +416,9 @@ internal static class FolderDialogUtil
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/'); var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized)) 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); initialDirPtr = Marshal.StringToHGlobalUni(normalized);
callback = (hwnd, uMsg, _, lpData) => callback = (hwnd, uMsg, _, lpData) =>
{ {
@@ -340,12 +441,12 @@ internal static class FolderDialogUtil
pidl = SHBrowseForFolderW(ref bi); pidl = SHBrowseForFolderW(ref bi);
if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero) if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero)
{ {
// If it returned null with ownerHandle, check if retry with IntPtr.Zero is appropriate // SHBrowseForFolderW returns NULL both on cancel and on failure, so no retry without the owner is made.
// But note: cancelling also returns Zero. We do not redundantly re-prompt if cancelled.
} }
if (pidl != IntPtr.Zero) if (pidl != IntPtr.Zero)
{ {
// SHGetPathFromIDListW writes up to MAX_PATH (260) characters.
var sb = new StringBuilder(260); var sb = new StringBuilder(260);
if (SHGetPathFromIDListW(pidl, sb)) if (SHGetPathFromIDListW(pidl, sb))
{ {
@@ -376,6 +477,10 @@ internal static class FolderDialogUtil
#region COM & Win32 Interop Definitions #region COM & Win32 Interop Definitions
/// <summary>
/// <c>FILEOPENDIALOGOPTIONS</c> flags passed to <c>IFileDialog::SetOptions</c> (subset; values from shobjidl_core.h).
/// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/ne-shobjidl_core-_fileopendialogoptions
/// </summary>
[Flags] [Flags]
private enum FOS : uint private enum FOS : uint
{ {
@@ -386,11 +491,20 @@ internal static class FolderDialogUtil
FOS_FILEMUSTEXIST = 0x00001000, FOS_FILEMUSTEXIST = 0x00001000,
} }
/// <summary>
/// Display name form requested from <c>IShellItem::GetDisplayName</c>; <c>SIGDN_FILESYSPATH</c> 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
/// </summary>
private enum SIGDN : uint private enum SIGDN : uint
{ {
SIGDN_FILESYSPATH = 0x80058000 SIGDN_FILESYSPATH = 0x80058000
} }
/// <summary>
/// COM interop declaration of the shell <c>IShellItem</c> interface (vtable order of shobjidl_core.h). Only
/// <c>GetDisplayName</c> is called; it keeps its HRESULT return (<c>PreserveSig</c>).
/// </summary>
[ComImport] [ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")] [Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
@@ -404,6 +518,11 @@ internal static class FolderDialogUtil
void Compare(IShellItem psi, uint hint, out int piOrder); void Compare(IShellItem psi, uint hint, out int piOrder);
} }
/// <summary>
/// COM interop declaration of <c>IFileOpenDialog</c>: the methods of <c>IModalWindow</c> (<c>Show</c>), <c>IFileDialog</c>
/// and <c>IFileOpenDialog</c> in vtable order. <c>Show</c> keeps its HRESULT return (<c>PreserveSig</c>); the other methods
/// throw <see cref="COMException"/> on a failure HRESULT.
/// </summary>
[ComImport] [ComImport]
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")] [Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
@@ -438,12 +557,15 @@ internal static class FolderDialogUtil
void GetSelectedItems(out IntPtr ppsai); 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)] [DllImport("ole32.dll", ExactSpelling = true)]
private static extern int OleInitialize(IntPtr pvReserved); private static extern int OleInitialize(IntPtr pvReserved);
// Balances one successful OleInitialize on the same thread.
[DllImport("ole32.dll", ExactSpelling = true)] [DllImport("ole32.dll", ExactSpelling = true)]
private static extern void OleUninitialize(); 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)] [DllImport("ole32.dll", ExactSpelling = true)]
private static extern int CoCreateInstance( private static extern int CoCreateInstance(
[In] ref Guid rclsid, [In] ref Guid rclsid,
@@ -452,6 +574,7 @@ internal static class FolderDialogUtil
[In] ref Guid riid, [In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IFileOpenDialog ppv); [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)] [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
private static extern int SHCreateItemFromParsingName( private static extern int SHCreateItemFromParsingName(
[MarshalAs(UnmanagedType.LPWStr)] string pszPath, [MarshalAs(UnmanagedType.LPWStr)] string pszPath,
@@ -459,14 +582,22 @@ internal static class FolderDialogUtil
[In] ref Guid riid, [In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IShellItem ppv); [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_RETURNONLYFSDIRS = 0x0001;
private const uint BIF_NEWDIALOGSTYLE = 0x0040; private const uint BIF_NEWDIALOGSTYLE = 0x0040;
private const uint BIF_EDITBOX = 0x0010; 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_INITIALIZED = 1;
private const uint BFFM_SETSELECTIONW = 0x0400 + 103; 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); 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)] [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct BROWSEINFO private struct BROWSEINFO
{ {
@@ -480,13 +611,16 @@ internal static class FolderDialogUtil
public int iImage; 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)] [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi); 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)] [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath); 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)] [DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
@@ -496,10 +630,21 @@ internal static class FolderDialogUtil
#region macOS Implementation #region macOS Implementation
/// <summary>
/// Runs <c>osascript -e</c> with the AppleScript <c>POSIX path of (choose folder with prompt ... [default location ...])</c>
/// 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.
/// </summary>
/// <param name="initialDirectory">Folder shown first; used only when it is an existing directory.</param>
/// <returns>
/// 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).
/// </returns>
private static string? OpenMacFolderBrowser(string initialDirectory) private static string? OpenMacFolderBrowser(string initialDirectory)
{ {
try try
{ {
// POSIX path of (...) converts the chosen folder alias to a slash-separated path.
string script; string script;
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory)) if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{ {
@@ -541,6 +686,17 @@ internal static class FolderDialogUtil
#region Linux Implementation #region Linux Implementation
/// <summary>
/// Runs <c>zenity --file-selection --directory --title=... [--filename=initialDirectory]</c> 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 <c>kdialog --getexistingdirectory [initialDirectory] --title ...</c> instead. Both commands print the
/// selected path to standard output.
/// </summary>
/// <param name="initialDirectory">Folder shown first; used only when it is an existing directory.</param>
/// <returns>
/// The selected existing directory; null when both dialogs return nothing, when kdialog cannot be started, or on an
/// exception (logged as a warning).
/// </returns>
private static string? OpenLinuxFolderBrowser(string initialDirectory) private static string? OpenLinuxFolderBrowser(string initialDirectory)
{ {
try try
+120 -8
View File
@@ -13,26 +13,36 @@ namespace ADOFAIRenderer.Util.OS;
/// (WinRT <c>ToastNotificationManager</c>, script passed with <c>-EncodedCommand</c>). /// (WinRT <c>ToastNotificationManager</c>, script passed with <c>-EncodedCommand</c>).
/// With a folder path, the script also registers the <c>adofai-renderer://</c> URL protocol under /// With a folder path, the script also registers the <c>adofai-renderer://</c> URL protocol under
/// <c>HKCU\Software\Classes\adofai-renderer</c> (open command: <c>explorer.exe "&lt;folder&gt;"</c>) /// <c>HKCU\Software\Classes\adofai-renderer</c> (open command: <c>explorer.exe "&lt;folder&gt;"</c>)
/// so the toast button opens the folder; the key is overwritten on each notification and stays registered. /// so the toast button opens the folder; the key is overwritten on each notification and stays registered, so every
/// toast opens the folder of the most recent notification.
/// When PowerShell fails, a Win32 <c>Shell_NotifyIcon</c> balloon is shown via P/Invoke. /// When PowerShell fails, a Win32 <c>Shell_NotifyIcon</c> balloon is shown via P/Invoke.
/// Runs on a dedicated background thread ("OSNotification-Win").
/// </para> /// </para>
/// <para> /// <para>
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>. /// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c> (no folder action), started from the
/// calling thread without waiting.
/// </para> /// </para>
/// <para> /// <para>
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions. /// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) and, when it cannot be started, <c>gdbus</c> calling
/// <c>org.freedesktop.Notifications.Notify</c>. With a folder path, <c>notify-send --action</c> runs on a thread pool task
/// and <c>xdg-open</c> opens the folder when the action is chosen.
/// </para> /// </para>
/// <para>Other platforms: nothing is shown. Failures are logged as warnings; no exception reaches the caller.</para>
/// </summary> /// </summary>
internal static class OSNotification internal static class OSNotification
{ {
/// <summary> /// <summary>
/// Sends a native desktop notification. /// Sends a native desktop notification and returns without waiting for it to be shown (see the class summary for the
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open /// thread used on each platform).
/// the folder when the notification is clicked/dismissed. /// If <paramref name="folderPath"/> is provided, Windows and Linux offer to open the folder from the notification.
/// Exceptions thrown while starting the platform implementation are caught and logged as warnings.
/// </summary> /// </summary>
/// <param name="title">The notification title.</param> /// <param name="title">The notification title.</param>
/// <param name="message">The notification body text.</param> /// <param name="message">The notification body text.</param>
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param> /// <param name="folderPath">
/// Optional folder path to open from the notification (Windows toast click or button / Windows balloon click / Linux action);
/// ignored on macOS.
/// </param>
public static void Show(string title, string message, string? folderPath = null) public static void Show(string title, string message, string? folderPath = null)
{ {
try try
@@ -65,6 +75,13 @@ internal static class OSNotification
/// </summary> /// </summary>
private static class WindowsNotification private static class WindowsNotification
{ {
/// <summary>
/// Starts a background thread ("OSNotification-Win") that tries the toast and shows the balloon when the toast fails.
/// Returns immediately. Exceptions on the thread are caught and logged.
/// </summary>
/// <param name="title">Notification title.</param>
/// <param name="message">Notification body text.</param>
/// <param name="folderPath">Folder to open from the notification, or null.</param>
public static void Send(string title, string message, string? folderPath) public static void Send(string title, string message, string? folderPath)
{ {
var thread = new Thread(() => var thread = new Thread(() =>
@@ -95,11 +112,23 @@ internal static class OSNotification
/// With a folder path, registers the adofai-renderer:// protocol in HKCU first (see class summary). /// With a folder path, registers the adofai-renderer:// protocol in HKCU first (see class summary).
/// Waits up to 5 seconds for the process. Returns true when it exits with code 0, /// Waits up to 5 seconds for the process. Returns true when it exits with code 0,
/// false when it exits with another code, does not exit within 5 seconds, or fails to start. /// false when it exits with another code, does not exit within 5 seconds, or fails to start.
/// <para>
/// The toast XML uses the <c>ToastGeneric</c> template with the title and message as two text elements; with a folder
/// path it also sets <c>launch</c> and an "Open Render Folder" action to the <c>adofai-renderer://</c> URI with protocol
/// activation. The notifier is created with the application ID "ADOFAI Renderer". The script is passed as Base64 of its
/// UTF-16LE bytes (<c>-EncodedCommand</c>), so no command line quoting is needed.
/// </para>
/// </summary> /// </summary>
/// <param name="title">Notification title (XML-escaped into the toast).</param>
/// <param name="message">Notification body text (XML-escaped into the toast).</param>
/// <param name="folderPath">Folder opened by the protocol handler, or null for a toast without action.</param>
/// <returns>True when PowerShell exited with code 0 within 5 seconds; false otherwise (the stderr text or exception is logged).</returns>
private static bool TrySendToast(string title, string message, string? folderPath) private static bool TrySendToast(string title, string message, string? folderPath)
{ {
try try
{ {
// SecurityElement.Escape replaces < > & " ' with XML entities, so the text also cannot end the
// single-quoted PowerShell string that holds the XML.
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title; string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message; string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
@@ -108,8 +137,13 @@ internal static class OSNotification
if (!string.IsNullOrEmpty(folderPath)) if (!string.IsNullOrEmpty(folderPath))
{ {
// A single quote is written as two single quotes inside a single-quoted PowerShell string.
var escapedFolder = folderPath.Replace("'", "''"); var escapedFolder = folderPath.Replace("'", "''");
// Script: register HKCU\Software\Classes\adofai-renderer as a URL protocol ("URL Protocol" value, default
// value "URL:adofai-renderer Protocol") whose shell\open\command runs explorer.exe on the folder, then load
// the WinRT types and show the toast.
toastXml = toastXml =
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" + "<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
"<visual><binding template=\"ToastGeneric\">" + "<visual><binding template=\"ToastGeneric\">" +
@@ -148,6 +182,7 @@ internal static class OSNotification
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))"; "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
} }
// -EncodedCommand takes Base64 of the UTF-16LE script text.
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript); byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
string base64 = Convert.ToBase64String(bytes); string base64 = Convert.ToBase64String(bytes);
@@ -166,7 +201,8 @@ internal static class OSNotification
process.Start(); process.Start();
process.WaitForExit(5000); process.WaitForExit(5000);
// If PowerShell exited with error, treat as failure // ExitCode throws InvalidOperationException while the process is still running (timeout), which the catch
// below reports as a failure. If PowerShell exited with error, treat as failure
if (process.ExitCode != 0) if (process.ExitCode != 0)
{ {
string stderr = process.StandardError.ReadToEnd().Trim(); string stderr = process.StandardError.ReadToEnd().Trim();
@@ -189,9 +225,14 @@ internal static class OSNotification
#region Balloon via Shell_NotifyIcon #region Balloon via Shell_NotifyIcon
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
// Shell_NotifyIcon messages: add / delete the notification area icon.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw
private const int NIM_ADD = 0x00; private const int NIM_ADD = 0x00;
private const int NIM_DELETE = 0x02; private const int NIM_DELETE = 0x02;
// NOTIFYICONDATAW.uFlags: uCallbackMessage, hIcon, szTip and the balloon fields (szInfo, szInfoTitle, dwInfoFlags)
// are valid. NIIF_INFO: information icon in the balloon.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataw
private const int NIF_MESSAGE = 0x01; private const int NIF_MESSAGE = 0x01;
private const int NIF_ICON = 0x02; private const int NIF_ICON = 0x02;
private const int NIF_TIP = 0x04; private const int NIF_TIP = 0x04;
@@ -199,18 +240,25 @@ internal static class OSNotification
private const int NIIF_INFO = 0x01; private const int NIIF_INFO = 0x01;
// WM_USER (winuser.h) starts the private window message range; WM_TRAYICON is the application-defined callback
// message of the icon. NIN_BALLOONUSERCLICK (shellapi.h, WM_USER + 5) arrives as lParam of the callback message when
// the user clicks the balloon.
private const int WM_USER = 0x0400; private const int WM_USER = 0x0400;
private const int WM_TRAYICON = WM_USER + 1; private const int WM_TRAYICON = WM_USER + 1;
private const int NIN_BALLOONUSERCLICK = 0x0405; private const int NIN_BALLOONUSERCLICK = 0x0405;
// Window style of the hidden message window (winuser.h), WM_CLOSE message ID (winuser.h), and the application-defined
// icon ID (any value unique for the window).
private const int WS_OVERLAPPED = 0x00000000; private const int WS_OVERLAPPED = 0x00000000;
private const uint WM_CLOSE = 0x0010; private const uint WM_CLOSE = 0x0010;
private const int TRAY_ICON_UID = 29471; private const int TRAY_ICON_UID = 29471;
// ReSharper restore InconsistentNaming // ReSharper restore InconsistentNaming
// Adds, modifies or deletes a notification area icon; returns false on failure.
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData); private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
// Creates the window that owns the icon and receives its callback messages; returns NULL on failure.
[DllImport("user32.dll", CharSet = CharSet.Unicode)] [DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr CreateWindowExW( private static extern IntPtr CreateWindowExW(
int dwExStyle, string lpClassName, string lpWindowName, int dwExStyle, string lpClassName, string lpWindowName,
@@ -221,9 +269,12 @@ internal static class OSNotification
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(IntPtr hWnd); private static extern bool DestroyWindow(IntPtr hWnd);
// Loads a predefined system icon when hInstance is NULL.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-loadiconw
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName); private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
// Message loop functions of the calling thread: GetMessageW blocks until a message arrives and returns false for WM_QUIT.
[DllImport("user32.dll")] [DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
@@ -234,10 +285,13 @@ internal static class OSNotification
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern IntPtr DispatchMessageW(ref Msg lpMsg); private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
// Places a message in the window's thread queue and returns without waiting (callable from any thread).
[DllImport("user32.dll")] [DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
// NOTIFYICONDATAW up to dwInfoFlags (without guidItem and hBalloonIcon); cbSize is the size of this layout.
// String fields hold at most SizeConst characters including the terminating null.
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconDataW private struct NotifyIconDataW
{ {
@@ -259,6 +313,7 @@ internal static class OSNotification
public int dwInfoFlags; public int dwInfoFlags;
} }
// Win32 MSG structure filled by GetMessageW.
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
private struct Msg private struct Msg
{ {
@@ -271,8 +326,22 @@ internal static class OSNotification
public int pt_y; public int pt_y;
} }
/// <summary>
/// Shows a notification area balloon through <c>Shell_NotifyIconW</c>, owned by a hidden message-only window.
/// <para>
/// Without a folder path the icon stays for 500 ms and is then removed. With a folder path the method runs a message
/// loop on the calling thread for up to 12 seconds (a timer posts <c>WM_CLOSE</c>); a balloon click
/// (<c>NIN_BALLOONUSERCLICK</c>) opens the folder through the shell (<c>UseShellExecute</c>) and ends the loop.
/// The icon is deleted and the window destroyed before returning. Errors are logged as warnings.
/// </para>
/// </summary>
/// <param name="title">Balloon title; truncated to 63 characters.</param>
/// <param name="message">Balloon text; truncated to 255 characters.</param>
/// <param name="folderPath">Folder opened on balloon click, or null.</param>
private static void SendBalloon(string title, string message, string? folderPath) private static void SendBalloon(string title, string message, string? folderPath)
{ {
// szInfoTitle holds 64 and szInfo 256 characters including the terminating null.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataw
if (title.Length > 63) title = title.Substring(0, 63); if (title.Length > 63) title = title.Substring(0, 63);
if (message.Length > 255) message = message.Substring(0, 255); if (message.Length > 255) message = message.Substring(0, 255);
@@ -281,6 +350,7 @@ internal static class OSNotification
try try
{ {
// HWND_MESSAGE ((HWND)-3, winuser.h) as parent creates a message-only window of the "Static" class.
var hwndMessage = new IntPtr(-3); var hwndMessage = new IntPtr(-3);
hWnd = CreateWindowExW( hWnd = CreateWindowExW(
0, "Static", "ADOFAIRendererNotify", 0, "Static", "ADOFAIRendererNotify",
@@ -293,6 +363,7 @@ internal static class OSNotification
return; return;
} }
// 32512 = IDI_APPLICATION (winuser.h), the default application icon.
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512); IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW)); nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
@@ -305,6 +376,7 @@ internal static class OSNotification
nid.szInfo = message; nid.szInfo = message;
nid.szInfoTitle = title; nid.szInfoTitle = title;
nid.dwInfoFlags = NIIF_INFO; nid.dwInfoFlags = NIIF_INFO;
// uTimeout in milliseconds; deprecated as of Windows Vista (display time follows the system settings).
nid.uTimeoutOrVersion = 4; nid.uTimeoutOrVersion = 4;
Shell_NotifyIconW(NIM_ADD, ref nid); Shell_NotifyIconW(NIM_ADD, ref nid);
@@ -327,6 +399,7 @@ internal static class OSNotification
if (msg.message == WM_TRAYICON) if (msg.message == WM_TRAYICON)
{ {
// No NIM_SETVERSION call: lParam carries the notification event (NIN_*) directly.
int notifyMsg = (int)(long)msg.lParam; int notifyMsg = (int)(long)msg.lParam;
if (notifyMsg == NIN_BALLOONUSERCLICK) if (notifyMsg == NIN_BALLOONUSERCLICK)
{ {
@@ -388,6 +461,12 @@ internal static class OSNotification
/// </summary> /// </summary>
private static class MacNotification private static class MacNotification
{ {
/// <summary>
/// Starts <c>osascript -e "display notification ... with title ..."</c> without waiting. Exceptions from starting the
/// process (for example osascript missing) propagate to <see cref="OSNotification.Show"/>.
/// </summary>
/// <param name="title">Notification title.</param>
/// <param name="message">Notification body text.</param>
public static void Send(string title, string message) public static void Send(string title, string message)
{ {
// AppleScript string escaping: backslash and double-quote // AppleScript string escaping: backslash and double-quote
@@ -412,6 +491,9 @@ internal static class OSNotification
RunProcess(startInfo); RunProcess(startInfo);
} }
/// <summary>Escapes a value for use inside a double-quoted AppleScript string literal.</summary>
/// <param name="value">Raw text.</param>
/// <returns>The text with each backslash and double quote preceded by a backslash.</returns>
private static string EscapeAppleScript(string value) private static string EscapeAppleScript(string value)
{ {
// In AppleScript string literals, escape backslash and double-quote // In AppleScript string literals, escape backslash and double-quote
@@ -431,6 +513,13 @@ internal static class OSNotification
/// </summary> /// </summary>
private static class LinuxNotification private static class LinuxNotification
{ {
/// <summary>
/// Shows the notification with an "Open Render Folder" action when <paramref name="folderPath"/> is set (thread pool
/// task), otherwise a plain notification started from the calling thread.
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <param name="folderPath">Folder opened by the action, or null.</param>
public static void Send(string title, string message, string? folderPath) public static void Send(string title, string message, string? folderPath)
{ {
if (!string.IsNullOrEmpty(folderPath)) if (!string.IsNullOrEmpty(folderPath))
@@ -443,6 +532,9 @@ internal static class OSNotification
} }
} }
/// <summary>Starts notify-send; when it cannot be started, starts gdbus. Neither process is waited for.</summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
private static void ShowSimple(string title, string message) private static void ShowSimple(string title, string message)
{ {
// Try notify-send first // Try notify-send first
@@ -453,6 +545,15 @@ internal static class OSNotification
TryGdbus(title, message); TryGdbus(title, message);
} }
/// <summary>
/// On a thread pool task, runs <c>notify-send -a "ADOFAI Renderer" --action=open_folder=Open Render Folder</c> and waits
/// for it to exit. notify-send prints the key of the chosen action ("open_folder") to standard output; in that case
/// <c>xdg-open &lt;folderPath&gt;</c> is started. When an exception occurs (for example notify-send missing), it is logged
/// and <see cref="ShowSimple"/> shows the notification without the action.
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <param name="folderPath">Folder passed to xdg-open.</param>
private static void ShowWithAction(string title, string message, string folderPath) private static void ShowWithAction(string title, string message, string folderPath)
{ {
// Run on a background thread because notify-send with --action blocks // Run on a background thread because notify-send with --action blocks
@@ -505,6 +606,10 @@ internal static class OSNotification
}); });
} }
/// <summary>Starts <c>notify-send -a "ADOFAI Renderer" &lt;title&gt; &lt;message&gt;</c> without waiting.</summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <returns>True when the process was started (its exit code is not checked); false when starting it threw.</returns>
private static bool TryNotifySend(string title, string message) private static bool TryNotifySend(string title, string message)
{ {
try try
@@ -530,6 +635,13 @@ internal static class OSNotification
} }
} }
/// <summary>
/// Starts <c>gdbus call --session</c> to <c>org.freedesktop.Notifications.Notify</c> (arguments app_name, replaces_id,
/// app_icon, summary, body, actions, hints, expire_timeout) without waiting. Start failures are logged as warnings.
/// Reference: https://specifications.freedesktop.org/notification/latest/protocol.html
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
private static void TryGdbus(string title, string message) private static void TryGdbus(string title, string message)
{ {
try try
+42
View File
@@ -2,16 +2,37 @@ using System;
namespace ADOFAIRenderer.Util.OS; namespace ADOFAIRenderer.Util.OS;
/// <summary>
/// Read-only access to <c>HKEY_LOCAL_MACHINE</c> (64-bit registry view) through reflection.
/// <para>
/// The mod targets netstandard2.1, which does not expose <c>Microsoft.Win32.RegistryKey</c>; the game runtime (Mono)
/// provides it in its core library. The types and methods are looked up once in the static constructor and invoked through
/// reflection.
/// </para>
/// <para>
/// Intended for Windows. On other platforms the result depends on the runtime's registry support; when the types or methods
/// are not found, or a call throws, every method returns null.
/// All failures (missing key, access denied, reflection errors) are caught and reported as null; nothing is logged.
/// Stateless and safe to call from any thread; each call opens and closes its own keys.
/// </para>
/// </summary>
internal static class RegistryUtil internal static class RegistryUtil
{ {
// Reflected Microsoft.Win32 types; null when the runtime does not provide them.
private static readonly Type? RegistryKeyType; private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType; private static readonly Type? RegistryHiveType;
private static readonly Type? RegistryViewType; private static readonly Type? RegistryViewType;
// RegistryKey.OpenBaseKey(RegistryHive, RegistryView) (static), OpenSubKey(string) (read-only open),
// GetSubKeyNames() and GetValue(string).
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod; private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod; private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod; private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
private static readonly System.Reflection.MethodInfo? GetValueMethod; private static readonly System.Reflection.MethodInfo? GetValueMethod;
/// <summary>
/// Resolves the registry types by name (<see cref="Type.GetType(string)"/>, then the core library that defines
/// <see cref="object"/>) and their methods. Leaves the fields null on any failure.
/// </summary>
static RegistryUtil() static RegistryUtil()
{ {
try try
@@ -42,6 +63,8 @@ internal static class RegistryUtil
} }
} }
/// <summary>Closes a reflected RegistryKey (Dispose, or Close when it is not IDisposable). Ignores null and errors.</summary>
/// <param name="key">RegistryKey instance returned by a reflected call, or null.</param>
private static void CloseKey(object? key) private static void CloseKey(object? key)
{ {
if (key == null || RegistryKeyType == null) return; if (key == null || RegistryKeyType == null) return;
@@ -63,6 +86,11 @@ internal static class RegistryUtil
} }
} }
/// <summary>
/// Returns the names of the direct subkeys of <c>HKEY_LOCAL_MACHINE\&lt;parentKeyPath&gt;</c> in the 64-bit registry view.
/// </summary>
/// <param name="parentKeyPath">Key path relative to HKEY_LOCAL_MACHINE, with backslash separators.</param>
/// <returns>Subkey names, or null when the key does not exist, cannot be opened, or the registry is unavailable.</returns>
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath) public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{ {
@@ -71,6 +99,10 @@ internal static class RegistryUtil
try try
{ {
// RegistryHive.LocalMachine = 0x80000002 (-2147483646), RegistryView.Registry64 = 256.
// On a 32-bit OS a Registry64 request returns the 32-bit view.
// Reference: https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.registryhive
// Reference: https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.registryview
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002); object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256); object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
@@ -95,6 +127,15 @@ internal static class RegistryUtil
} }
} }
/// <summary>
/// Reads a string value of <c>HKEY_LOCAL_MACHINE\&lt;keyPath&gt;</c> in the 64-bit registry view.
/// </summary>
/// <param name="keyPath">Key path relative to HKEY_LOCAL_MACHINE, with backslash separators.</param>
/// <param name="valueName">Value name; an empty string reads the key's default value.</param>
/// <returns>
/// The value when it exists and is a string (REG_SZ / REG_EXPAND_SZ); null when the key or value does not exist, the value
/// has another type, or the registry is unavailable.
/// </returns>
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName) public static string? GetHKLMValue(string keyPath, string valueName)
{ {
@@ -103,6 +144,7 @@ internal static class RegistryUtil
try try
{ {
// Same hive and view constants as GetHKLMSubKeyNames.
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002); object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256); object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);