diff --git a/Util/OS/FolderDialogUtil.cs b/Util/OS/FolderDialogUtil.cs
index ccae4fc..3f6d4dc 100644
--- a/Util/OS/FolderDialogUtil.cs
+++ b/Util/OS/FolderDialogUtil.cs
@@ -10,12 +10,49 @@ 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)
@@ -24,12 +61,18 @@ internal static class FolderDialogUtil
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;
@@ -45,6 +88,8 @@ internal static class FolderDialogUtil
}
finally
{
+ // OleInitialize returns S_OK or S_FALSE (already initialized) on success; each successful call is
+ // balanced by one OleUninitialize.
if (oleHr >= 0)
{
try
@@ -98,6 +143,13 @@ internal static class FolderDialogUtil
}
}
+ ///
+ /// 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)
@@ -129,20 +181,32 @@ internal static class FolderDialogUtil
#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
@@ -175,6 +239,13 @@ internal static class FolderDialogUtil
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
@@ -188,16 +259,31 @@ internal static class FolderDialogUtil
}
}
+ ///
+ /// 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. 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 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
+ // 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)
{
@@ -223,6 +309,7 @@ internal static class FolderDialogUtil
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)
@@ -293,8 +380,19 @@ internal static class FolderDialogUtil
}
}
+ ///
+ /// 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;
@@ -318,6 +416,9 @@ internal static class FolderDialogUtil
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) =>
{
@@ -340,12 +441,12 @@ internal static class FolderDialogUtil
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.
+ // 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))
{
@@ -376,6 +477,10 @@ internal static class FolderDialogUtil
#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
{
@@ -386,11 +491,20 @@ internal static class FolderDialogUtil
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)]
@@ -404,6 +518,11 @@ internal static class FolderDialogUtil
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)]
@@ -438,12 +557,15 @@ internal static class FolderDialogUtil
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,
@@ -452,6 +574,7 @@ internal static class FolderDialogUtil
[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,
@@ -459,14 +582,22 @@ internal static class FolderDialogUtil
[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
{
@@ -480,13 +611,16 @@ internal static class FolderDialogUtil
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);
@@ -496,10 +630,21 @@ internal static class FolderDialogUtil
#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))
{
@@ -541,6 +686,17 @@ internal static class FolderDialogUtil
#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
diff --git a/Util/OS/OSNotification.cs b/Util/OS/OSNotification.cs
index c755050..2f2f248 100644
--- a/Util/OS/OSNotification.cs
+++ b/Util/OS/OSNotification.cs
@@ -13,26 +13,36 @@ namespace ADOFAIRenderer.Util.OS;
/// (WinRT ToastNotificationManager, script passed with -EncodedCommand).
/// With a folder path, the script also registers the adofai-renderer:// URL protocol under
/// HKCU\Software\Classes\adofai-renderer (open command: explorer.exe "<folder>")
-/// 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 Shell_NotifyIcon balloon is shown via P/Invoke.
+/// Runs on a dedicated background thread ("OSNotification-Win").
///
///
-/// - macOS: Uses osascript with AppleScript's display notification.
+/// - macOS: Uses osascript with AppleScript's display notification (no folder action), started from the
+/// calling thread without waiting.
///
///
-/// - Linux: Uses notify-send (libnotify) with xdg-open for folder actions.
+/// - Linux: Uses notify-send (libnotify) and, when it cannot be started, gdbus calling
+/// org.freedesktop.Notifications.Notify. With a folder path, notify-send --action runs on a thread pool task
+/// and xdg-open opens the folder when the action is chosen.
///
+/// Other platforms: nothing is shown. Failures are logged as warnings; no exception reaches the caller.
///
internal static class OSNotification
{
///
- /// Sends a native desktop notification.
- /// If is provided, Windows and Linux will attempt to open
- /// the folder when the notification is clicked/dismissed.
+ /// Sends a native desktop notification and returns without waiting for it to be shown (see the class summary for the
+ /// thread used on each platform).
+ /// If 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.
///
/// The notification title.
/// The notification body text.
- /// Optional folder path to open on click (Windows balloon click / Linux action).
+ ///
+ /// Optional folder path to open from the notification (Windows toast click or button / Windows balloon click / Linux action);
+ /// ignored on macOS.
+ ///
public static void Show(string title, string message, string? folderPath = null)
{
try
@@ -65,6 +75,13 @@ internal static class OSNotification
///
private static class WindowsNotification
{
+ ///
+ /// 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.
+ ///
+ /// Notification title.
+ /// Notification body text.
+ /// Folder to open from the notification, or null.
public static void Send(string title, string message, string? folderPath)
{
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).
/// 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.
+ ///
+ /// The toast XML uses the ToastGeneric template with the title and message as two text elements; with a folder
+ /// path it also sets launch and an "Open Render Folder" action to the adofai-renderer:// 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 (-EncodedCommand), so no command line quoting is needed.
+ ///
///
+ /// Notification title (XML-escaped into the toast).
+ /// Notification body text (XML-escaped into the toast).
+ /// Folder opened by the protocol handler, or null for a toast without action.
+ /// True when PowerShell exited with code 0 within 5 seconds; false otherwise (the stderr text or exception is logged).
private static bool TrySendToast(string title, string message, string? folderPath)
{
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 xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
@@ -108,8 +137,13 @@ internal static class OSNotification
if (!string.IsNullOrEmpty(folderPath))
{
+ // A single quote is written as two single quotes inside a single-quoted PowerShell string.
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 =
"" +
"" +
@@ -148,6 +182,7 @@ internal static class OSNotification
"[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);
string base64 = Convert.ToBase64String(bytes);
@@ -166,7 +201,8 @@ internal static class OSNotification
process.Start();
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)
{
string stderr = process.StandardError.ReadToEnd().Trim();
@@ -189,9 +225,14 @@ internal static class OSNotification
#region Balloon via Shell_NotifyIcon
// 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_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_ICON = 0x02;
private const int NIF_TIP = 0x04;
@@ -199,18 +240,25 @@ internal static class OSNotification
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_TRAYICON = WM_USER + 1;
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 uint WM_CLOSE = 0x0010;
private const int TRAY_ICON_UID = 29471;
// ReSharper restore InconsistentNaming
+ // Adds, modifies or deletes a notification area icon; returns false on failure.
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
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)]
private static extern IntPtr CreateWindowExW(
int dwExStyle, string lpClassName, string lpWindowName,
@@ -221,9 +269,12 @@ internal static class OSNotification
[return: MarshalAs(UnmanagedType.Bool)]
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")]
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")]
[return: MarshalAs(UnmanagedType.Bool)]
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")]
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")]
[return: MarshalAs(UnmanagedType.Bool)]
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)]
private struct NotifyIconDataW
{
@@ -259,6 +313,7 @@ internal static class OSNotification
public int dwInfoFlags;
}
+ // Win32 MSG structure filled by GetMessageW.
[StructLayout(LayoutKind.Sequential)]
private struct Msg
{
@@ -271,8 +326,22 @@ internal static class OSNotification
public int pt_y;
}
+ ///
+ /// Shows a notification area balloon through Shell_NotifyIconW, owned by a hidden message-only window.
+ ///
+ /// 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 WM_CLOSE); a balloon click
+ /// (NIN_BALLOONUSERCLICK) opens the folder through the shell (UseShellExecute) and ends the loop.
+ /// The icon is deleted and the window destroyed before returning. Errors are logged as warnings.
+ ///
+ ///
+ /// Balloon title; truncated to 63 characters.
+ /// Balloon text; truncated to 255 characters.
+ /// Folder opened on balloon click, or null.
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 (message.Length > 255) message = message.Substring(0, 255);
@@ -281,6 +350,7 @@ internal static class OSNotification
try
{
+ // HWND_MESSAGE ((HWND)-3, winuser.h) as parent creates a message-only window of the "Static" class.
var hwndMessage = new IntPtr(-3);
hWnd = CreateWindowExW(
0, "Static", "ADOFAIRendererNotify",
@@ -293,6 +363,7 @@ internal static class OSNotification
return;
}
+ // 32512 = IDI_APPLICATION (winuser.h), the default application icon.
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
@@ -305,6 +376,7 @@ internal static class OSNotification
nid.szInfo = message;
nid.szInfoTitle = title;
nid.dwInfoFlags = NIIF_INFO;
+ // uTimeout in milliseconds; deprecated as of Windows Vista (display time follows the system settings).
nid.uTimeoutOrVersion = 4;
Shell_NotifyIconW(NIM_ADD, ref nid);
@@ -327,6 +399,7 @@ internal static class OSNotification
if (msg.message == WM_TRAYICON)
{
+ // No NIM_SETVERSION call: lParam carries the notification event (NIN_*) directly.
int notifyMsg = (int)(long)msg.lParam;
if (notifyMsg == NIN_BALLOONUSERCLICK)
{
@@ -388,6 +461,12 @@ internal static class OSNotification
///
private static class MacNotification
{
+ ///
+ /// Starts osascript -e "display notification ... with title ..." without waiting. Exceptions from starting the
+ /// process (for example osascript missing) propagate to .
+ ///
+ /// Notification title.
+ /// Notification body text.
public static void Send(string title, string message)
{
// AppleScript string escaping: backslash and double-quote
@@ -412,6 +491,9 @@ internal static class OSNotification
RunProcess(startInfo);
}
+ /// Escapes a value for use inside a double-quoted AppleScript string literal.
+ /// Raw text.
+ /// The text with each backslash and double quote preceded by a backslash.
private static string EscapeAppleScript(string value)
{
// In AppleScript string literals, escape backslash and double-quote
@@ -431,6 +513,13 @@ internal static class OSNotification
///
private static class LinuxNotification
{
+ ///
+ /// Shows the notification with an "Open Render Folder" action when is set (thread pool
+ /// task), otherwise a plain notification started from the calling thread.
+ ///
+ /// Notification summary.
+ /// Notification body.
+ /// Folder opened by the action, or null.
public static void Send(string title, string message, string? folderPath)
{
if (!string.IsNullOrEmpty(folderPath))
@@ -443,6 +532,9 @@ internal static class OSNotification
}
}
+ /// Starts notify-send; when it cannot be started, starts gdbus. Neither process is waited for.
+ /// Notification summary.
+ /// Notification body.
private static void ShowSimple(string title, string message)
{
// Try notify-send first
@@ -453,6 +545,15 @@ internal static class OSNotification
TryGdbus(title, message);
}
+ ///
+ /// On a thread pool task, runs notify-send -a "ADOFAI Renderer" --action=open_folder=Open Render Folder and waits
+ /// for it to exit. notify-send prints the key of the chosen action ("open_folder") to standard output; in that case
+ /// xdg-open <folderPath> is started. When an exception occurs (for example notify-send missing), it is logged
+ /// and shows the notification without the action.
+ ///
+ /// Notification summary.
+ /// Notification body.
+ /// Folder passed to xdg-open.
private static void ShowWithAction(string title, string message, string folderPath)
{
// Run on a background thread because notify-send with --action blocks
@@ -505,6 +606,10 @@ internal static class OSNotification
});
}
+ /// Starts notify-send -a "ADOFAI Renderer" <title> <message> without waiting.
+ /// Notification summary.
+ /// Notification body.
+ /// True when the process was started (its exit code is not checked); false when starting it threw.
private static bool TryNotifySend(string title, string message)
{
try
@@ -530,6 +635,13 @@ internal static class OSNotification
}
}
+ ///
+ /// Starts gdbus call --session to org.freedesktop.Notifications.Notify (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
+ ///
+ /// Notification summary.
+ /// Notification body.
private static void TryGdbus(string title, string message)
{
try
diff --git a/Util/OS/RegistryUtil.cs b/Util/OS/RegistryUtil.cs
index 91ecfc6..11467cf 100644
--- a/Util/OS/RegistryUtil.cs
+++ b/Util/OS/RegistryUtil.cs
@@ -2,16 +2,37 @@ using System;
namespace ADOFAIRenderer.Util.OS;
+///
+/// Read-only access to HKEY_LOCAL_MACHINE (64-bit registry view) through reflection.
+///
+/// The mod targets netstandard2.1, which does not expose Microsoft.Win32.RegistryKey; 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.
+///
+///
+/// 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.
+///
+///
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? RegistryHiveType;
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? OpenSubKeyMethod;
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
private static readonly System.Reflection.MethodInfo? GetValueMethod;
+ ///
+ /// Resolves the registry types by name (, then the core library that defines
+ /// ) and their methods. Leaves the fields null on any failure.
+ ///
static RegistryUtil()
{
try
@@ -42,6 +63,8 @@ internal static class RegistryUtil
}
}
+ /// Closes a reflected RegistryKey (Dispose, or Close when it is not IDisposable). Ignores null and errors.
+ /// RegistryKey instance returned by a reflected call, or null.
private static void CloseKey(object? key)
{
if (key == null || RegistryKeyType == null) return;
@@ -63,6 +86,11 @@ internal static class RegistryUtil
}
}
+ ///
+ /// Returns the names of the direct subkeys of HKEY_LOCAL_MACHINE\<parentKeyPath> in the 64-bit registry view.
+ ///
+ /// Key path relative to HKEY_LOCAL_MACHINE, with backslash separators.
+ /// Subkey names, or null when the key does not exist, cannot be opened, or the registry is unavailable.
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
@@ -71,6 +99,10 @@ internal static class RegistryUtil
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 viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
@@ -95,6 +127,15 @@ internal static class RegistryUtil
}
}
+ ///
+ /// Reads a string value of HKEY_LOCAL_MACHINE\<keyPath> in the 64-bit registry view.
+ ///
+ /// Key path relative to HKEY_LOCAL_MACHINE, with backslash separators.
+ /// Value name; an empty string reads the key's default value.
+ ///
+ /// 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.
+ ///
// ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName)
{
@@ -103,6 +144,7 @@ internal static class RegistryUtil
try
{
+ // Same hive and view constants as GetHKLMSubKeyNames.
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);