From a389283d334a5db3fe52d7e3d2a08d1965e6ac2e8f07c8d5e3e7a3741dd7c1ac Mon Sep 17 00:00:00 2001 From: copecone Date: Thu, 15 Oct 2026 16:17:07 +0900 Subject: [PATCH] Document OS Utilities --- Util/OS/FolderDialogUtil.cs | 164 +++++++++++++++++++++++++++++++++++- Util/OS/OSNotification.cs | 128 ++++++++++++++++++++++++++-- Util/OS/RegistryUtil.cs | 42 +++++++++ 3 files changed, 322 insertions(+), 12 deletions(-) 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);