SHA256
Document OS Utilities
This commit is contained in:
1 parent
d2bd655118
commit
a389283d33
3 files changed
+322
-12
No files matched your search
+160
-4
@@ -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
@@ -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 "<folder>"</c>)
|
/// <c>HKCU\Software\Classes\adofai-renderer</c> (open command: <c>explorer.exe "<folder>"</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 <folderPath></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" <title> <message></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
|
||||||
|
|||||||
@@ -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\<parentKeyPath></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\<keyPath></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);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user