Move OS-dependent Utilities Into OS Package

This commit is contained in:
copecone committed 2026-10-11 16:44:04 +09:00
1 parent dee3b2dbcf
commit 7aa8ae4b54
8 files changed
+710 -710

No files matched your search

+615
View File
@@ -0,0 +1,615 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MelonLoader;
namespace ADOFAIRenderer.Util.OS;
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")]
internal static class FolderDialogUtil
{
private static int _isDialogOpen;
public static void OpenFolderBrowser(string initialDirectory, Action<string?> onSelected)
{
if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0)
{
MelonLogger.Msg("[FolderDialogUtil] Folder dialog is already open.");
return;
}
var syncContext = SynchronizationContext.Current;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var ownerHandle = GetGameWindowHandle();
var thread = new Thread(() =>
{
string? selected = null;
int oleHr = -1;
try
{
oleHr = OleInitialize(IntPtr.Zero);
selected = OpenWindowsFolderBrowser(initialDirectory, ownerHandle);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}");
}
finally
{
if (oleHr >= 0)
{
try
{
OleUninitialize();
}
catch
{
// Ignore uninitialize error
}
}
Interlocked.Exchange(ref _isDialogOpen, 0);
}
DispatchResult(syncContext, onSelected, selected);
})
{
IsBackground = true,
Name = "FolderDialog-Win"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
else
{
Task.Run(() =>
{
string? selected = null;
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
selected = OpenMacFolderBrowser(initialDirectory);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
selected = OpenLinuxFolderBrowser(initialDirectory);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}");
}
finally
{
Interlocked.Exchange(ref _isDialogOpen, 0);
}
DispatchResult(syncContext, onSelected, selected);
});
}
}
private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected)
{
if (syncContext != null)
{
syncContext.Post(_ =>
{
try
{
onSelected(selected);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}");
}
}, null);
}
else
{
try
{
onSelected(selected);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}");
}
}
}
#region Windows Implementation
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");
private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7);
[DllImport("user32.dll")]
private static extern IntPtr GetActiveWindow();
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
private static IntPtr GetGameWindowHandle()
{
try
{
var active = GetActiveWindow();
if (active != IntPtr.Zero) return active;
var currentPid = (uint)Process.GetCurrentProcess().Id;
var fg = GetForegroundWindow();
if (fg != IntPtr.Zero)
{
GetWindowThreadProcessId(fg, out var fgPid);
if (fgPid == currentPid)
{
return fg;
}
}
var mainHandle = Process.GetCurrentProcess().MainWindowHandle;
if (mainHandle != IntPtr.Zero)
{
return mainHandle;
}
}
catch
{
// Ignore failure to detect window handle
}
return IntPtr.Zero;
}
private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle)
{
try
{
return OpenWindowsFolderBrowserModern(initialDirectory, ownerHandle);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Modern folder dialog failed ({ex.Message}), falling back to legacy dialog.");
return OpenWindowsFolderBrowserLegacy(initialDirectory, ownerHandle);
}
}
private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle)
{
// 1. Try native CoCreateInstance first (works on Mono and .NET without class registration)
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
var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog);
if (comType != null)
{
var instance = Activator.CreateInstance(comType);
dialog = instance as IFileOpenDialog;
}
}
if (dialog == null)
{
throw new COMException("Failed to instantiate IFileOpenDialog via COM.", createHr);
}
try
{
dialog.SetOptions(FOS.FOS_PICKFOLDERS | FOS.FOS_FORCEFILESYSTEM | FOS.FOS_PATHMUSTEXIST);
dialog.SetTitle("Select Output Directory");
if (!string.IsNullOrWhiteSpace(initialDirectory))
{
try
{
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized))
{
var shellItemGuid = IID_IShellItem;
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
if (itemHr == 0)
{
try
{
dialog.SetFolder(initialItem);
dialog.SetDefaultFolder(initialItem);
}
finally
{
Marshal.ReleaseComObject(initialItem);
}
}
}
}
catch
{
// Ignore failure to set initial folder
}
}
int hr = dialog.Show(ownerHandle);
if (hr != 0 && hr != ERROR_CANCELLED_HR && ownerHandle != IntPtr.Zero)
{
// If Show failed with ownerHandle (e.g. cross-thread or invalid handle), retry without owner
MelonLogger.Msg($"[FolderDialogUtil] dialog.Show(ownerHandle) returned 0x{hr:X8}, retrying with IntPtr.Zero.");
hr = dialog.Show(IntPtr.Zero);
}
if (hr == ERROR_CANCELLED_HR)
{
return null;
}
if (hr != 0)
{
throw new COMException("IFileOpenDialog.Show failed", hr);
}
dialog.GetResult(out var resultItem);
try
{
if (resultItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var pszPath) == 0 && pszPath != IntPtr.Zero)
{
try
{
var path = Marshal.PtrToStringUni(pszPath);
return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null;
}
finally
{
Marshal.FreeCoTaskMem(pszPath);
}
}
}
finally
{
Marshal.ReleaseComObject(resultItem);
}
return null;
}
finally
{
Marshal.ReleaseComObject(dialog);
}
}
private static string? OpenWindowsFolderBrowserLegacy(string initialDirectory, IntPtr ownerHandle)
{
IntPtr pszDisplayName = Marshal.AllocHGlobal(260 * sizeof(char));
IntPtr initialDirPtr = IntPtr.Zero;
BrowseCallbackProc? callback = null;
IntPtr pidl = IntPtr.Zero;
try
{
var bi = new BROWSEINFO
{
hwndOwner = ownerHandle,
pidlRoot = IntPtr.Zero,
pszDisplayName = pszDisplayName,
lpszTitle = "Select Output Directory",
ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE | BIF_EDITBOX
};
if (!string.IsNullOrWhiteSpace(initialDirectory))
{
try
{
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized))
{
initialDirPtr = Marshal.StringToHGlobalUni(normalized);
callback = (hwnd, uMsg, _, lpData) =>
{
if (uMsg == BFFM_INITIALIZED && lpData != IntPtr.Zero)
{
SendMessage(hwnd, BFFM_SETSELECTIONW, (IntPtr)1, lpData);
}
return 0;
};
bi.lpfn = callback;
bi.lParam = initialDirPtr;
}
}
catch
{
// Ignore failure to prepare initial directory
}
}
pidl = SHBrowseForFolderW(ref bi);
if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero)
{
// 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.
}
if (pidl != IntPtr.Zero)
{
var sb = new StringBuilder(260);
if (SHGetPathFromIDListW(pidl, sb))
{
var path = sb.ToString();
return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null;
}
}
return null;
}
finally
{
if (pidl != IntPtr.Zero)
{
Marshal.FreeCoTaskMem(pidl);
}
if (initialDirPtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(initialDirPtr);
}
if (pszDisplayName != IntPtr.Zero)
{
Marshal.FreeHGlobal(pszDisplayName);
}
GC.KeepAlive(callback);
}
}
#region COM & Win32 Interop Definitions
[Flags]
private enum FOS : uint
{
FOS_NOCHANGEDIR = 0x00000008,
FOS_PICKFOLDERS = 0x00000020,
FOS_FORCEFILESYSTEM = 0x00000040,
FOS_PATHMUSTEXIST = 0x00000800,
FOS_FILEMUSTEXIST = 0x00001000,
}
private enum SIGDN : uint
{
SIGDN_FILESYSPATH = 0x80058000
}
[ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItem
{
void BindToHandler(IntPtr pbc, [In] ref Guid bhid, [In] ref Guid riid, out IntPtr ppv);
void GetParent(out IShellItem ppsi);
[PreserveSig]
int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName);
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
void Compare(IShellItem psi, uint hint, out int piOrder);
}
[ComImport]
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileOpenDialog
{
[PreserveSig]
int Show(IntPtr parent);
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
void SetFileTypeIndex(uint iFileType);
void GetFileTypeIndex(out uint piFileType);
void Advise(IntPtr pfde, out uint pdwCookie);
void Unadvise(uint dwCookie);
void SetOptions(FOS fos);
void GetOptions(out FOS pfos);
void SetDefaultFolder(IShellItem psi);
void SetFolder(IShellItem psi);
void GetFolder(out IShellItem ppsi);
void GetCurrentSelection(out IShellItem ppsi);
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
void GetResult(out IShellItem ppsi);
void AddPlace(IShellItem psi, int fdap);
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
void Close(int hr);
void SetClientGuid(ref Guid guid);
void ClearClientData();
void SetFilter(IntPtr pFilter);
void GetResults(out IntPtr ppenum);
void GetSelectedItems(out IntPtr ppsai);
}
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern int OleInitialize(IntPtr pvReserved);
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern void OleUninitialize();
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern int CoCreateInstance(
[In] ref Guid rclsid,
IntPtr pUnkOuter,
uint dwClsContext,
[In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IFileOpenDialog ppv);
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
private static extern int SHCreateItemFromParsingName(
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
IntPtr pbc,
[In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
private const uint BIF_RETURNONLYFSDIRS = 0x0001;
private const uint BIF_NEWDIALOGSTYLE = 0x0040;
private const uint BIF_EDITBOX = 0x0010;
private const uint BFFM_INITIALIZED = 1;
private const uint BFFM_SETSELECTIONW = 0x0400 + 103;
private delegate int BrowseCallbackProc(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct BROWSEINFO
{
public IntPtr hwndOwner;
public IntPtr pidlRoot;
public IntPtr pszDisplayName;
public string lpszTitle;
public uint ulFlags;
public BrowseCallbackProc? lpfn;
public IntPtr lParam;
public int iImage;
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
#endregion
#endregion
#region macOS Implementation
private static string? OpenMacFolderBrowser(string initialDirectory)
{
try
{
string script;
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
var escaped = initialDirectory.Replace("\\", "\\\\").Replace("\"", "\\\"");
script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location (POSIX file \"{escaped}\"))";
}
else
{
script = "POSIX path of (choose folder with prompt \"Select Output Directory\")";
}
var startInfo = new ProcessStartInfo
{
FileName = "osascript",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-e");
startInfo.ArgumentList.Add(script);
using var process = Process.Start(startInfo);
if (process == null) return null;
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Mac folder dialog error: {ex.Message}");
return null;
}
}
#endregion
#region Linux Implementation
private static string? OpenLinuxFolderBrowser(string initialDirectory)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "zenity",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("--file-selection");
startInfo.ArgumentList.Add("--directory");
startInfo.ArgumentList.Add("--title=Select Output Directory");
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
startInfo.ArgumentList.Add($"--filename={initialDirectory}");
}
try
{
using var process = Process.Start(startInfo);
if (process != null)
{
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output))
{
return output;
}
}
}
catch
{
// Fallback to kdialog if zenity fails to start
}
var kdialogInfo = new ProcessStartInfo
{
FileName = "kdialog",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
kdialogInfo.ArgumentList.Add("--getexistingdirectory");
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
kdialogInfo.ArgumentList.Add(initialDirectory);
}
kdialogInfo.ArgumentList.Add("--title");
kdialogInfo.ArgumentList.Add("Select Output Directory");
using var kdialogProcess = Process.Start(kdialogInfo);
if (kdialogProcess == null) return null;
var koutput = kdialogProcess.StandardOutput.ReadToEnd().Trim();
kdialogProcess.WaitForExit();
return !string.IsNullOrEmpty(koutput) && Directory.Exists(koutput) ? koutput : null;
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Linux folder dialog error: {ex.Message}");
return null;
}
}
#endregion
}
+577
View File
@@ -0,0 +1,577 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.OS;
/// <summary>
/// Sends native desktop notifications on Windows, macOS, and Linux.
/// <para>
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
/// No PowerShell, no UWP, no WinRT dependencies.
/// </para>
/// <para>
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
/// </para>
/// <para>
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
/// </para>
/// </summary>
internal static class OSNotification
{
/// <summary>
/// Sends a native desktop notification.
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
/// the folder when the notification is clicked/dismissed.
/// </summary>
/// <param name="title">The notification title.</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>
public static void Show(string title, string message, string? folderPath = null)
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
WindowsNotification.Send(title, message, folderPath);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
MacNotification.Send(title, message);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
LinuxNotification.Send(title, message, folderPath);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
}
}
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
/// <summary>
/// Windows notification with two strategies:
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
/// </summary>
private static class WindowsNotification
{
public static void Send(string title, string message, string? folderPath)
{
var thread = new Thread(() =>
{
try
{
if (!TrySendToast(title, message, folderPath))
{
SendBalloon(title, message, folderPath);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
}
})
{
IsBackground = true,
Name = "OSNotification-Win"
};
thread.Start();
}
#region Toast via PowerShell
/// <summary>
/// Attempts to show a Windows Toast notification via PowerShell.
/// Returns true if the process launched successfully, false otherwise.
/// </summary>
private static bool TrySendToast(string title, string message, string? folderPath)
{
try
{
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
string toastXml;
string psScript;
if (!string.IsNullOrEmpty(folderPath))
{
var escapedFolder = folderPath.Replace("'", "''");
toastXml =
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
"<visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual>" +
"<actions>" +
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
"</actions></toast>";
psScript =
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
"$null = New-Item -Path $protocolPath -Force\n" +
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
"$null = New-Item -Path $cmdPath -Force\n" +
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
$"$xml.LoadXml('{toastXml}')\n" +
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
else
{
toastXml =
"<toast><visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual></toast>";
psScript =
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
$"$xml.LoadXml('{toastXml}')\n" +
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
string base64 = Convert.ToBase64String(bytes);
var startInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
process.WaitForExit(5000);
// If PowerShell exited with error, treat as failure
if (process.ExitCode != 0)
{
string stderr = process.StandardError.ReadToEnd().Trim();
if (!string.IsNullOrEmpty(stderr))
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
return false;
}
return true;
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
return false;
}
}
#endregion
#region Balloon via Shell_NotifyIcon
// ReSharper disable InconsistentNaming
private const int NIM_ADD = 0x00;
private const int NIM_DELETE = 0x02;
private const int NIF_MESSAGE = 0x01;
private const int NIF_ICON = 0x02;
private const int NIF_TIP = 0x04;
private const int NIF_INFO = 0x10;
private const int NIIF_INFO = 0x01;
private const int WM_USER = 0x0400;
private const int WM_TRAYICON = WM_USER + 1;
private const int NIN_BALLOONUSERCLICK = 0x0405;
private const int WS_OVERLAPPED = 0x00000000;
private const uint WM_CLOSE = 0x0010;
private const int TRAY_ICON_UID = 29471;
// ReSharper restore InconsistentNaming
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr CreateWindowExW(
int dwExStyle, string lpClassName, string lpWindowName,
int dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconDataW
{
public int cbSize;
public IntPtr hWnd;
public int uID;
public int uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public int dwState;
public int dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
public int uTimeoutOrVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public int dwInfoFlags;
}
[StructLayout(LayoutKind.Sequential)]
private struct Msg
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int pt_x;
public int pt_y;
}
private static void SendBalloon(string title, string message, string? folderPath)
{
if (title.Length > 63) title = title.Substring(0, 63);
if (message.Length > 255) message = message.Substring(0, 255);
IntPtr hWnd = IntPtr.Zero;
var nid = new NotifyIconDataW();
try
{
var hwndMessage = new IntPtr(-3);
hWnd = CreateWindowExW(
0, "Static", "ADOFAIRendererNotify",
WS_OVERLAPPED, 0, 0, 0, 0,
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
if (hWnd == IntPtr.Zero)
{
MelonLogger.Warning("[OSNotification] Cannot create message window.");
return;
}
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
nid.hWnd = hWnd;
nid.uID = TRAY_ICON_UID;
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
nid.uCallbackMessage = WM_TRAYICON;
nid.hIcon = hIcon;
nid.szTip = "ADOFAI Renderer";
nid.szInfo = message;
nid.szInfoTitle = title;
nid.dwInfoFlags = NIIF_INFO;
nid.uTimeoutOrVersion = 4;
Shell_NotifyIconW(NIM_ADD, ref nid);
if (!string.IsNullOrEmpty(folderPath))
{
var capturedFolder = folderPath;
var closeTimer = new System.Threading.Timer(_ =>
{
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
catch { /* ignored */ }
}, null, 12000, Timeout.Infinite);
try
{
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
{
if (msg.message == WM_CLOSE)
break;
if (msg.message == WM_TRAYICON)
{
int notifyMsg = (int)(long)msg.lParam;
if (notifyMsg == NIN_BALLOONUSERCLICK)
{
try
{
var psi = new ProcessStartInfo();
psi.FileName = capturedFolder;
psi.UseShellExecute = true;
Process.Start(psi);
}
catch (Exception ex)
{
MelonLogger.Warning(
$"[OSNotification] Cannot open folder: {ex.Message}");
}
break;
}
}
TranslateMessage(ref msg);
DispatchMessageW(ref msg);
}
}
finally
{
closeTimer.Dispose();
}
}
else
{
Thread.Sleep(500);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
}
finally
{
if (hWnd != IntPtr.Zero)
{
nid.uFlags = 0;
Shell_NotifyIconW(NIM_DELETE, ref nid);
DestroyWindow(hWnd);
}
}
}
#endregion
}
#endregion
#region macOS — osascript
/// <summary>
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
/// </summary>
private static class MacNotification
{
public static void Send(string title, string message)
{
// AppleScript string escaping: backslash and double-quote
string escapedTitle = EscapeAppleScript(title);
string escapedMessage = EscapeAppleScript(message);
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
// Each argument is passed directly to the process without shell interpretation.
var script =
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
var startInfo = new ProcessStartInfo
{
FileName = "osascript",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-e");
startInfo.ArgumentList.Add(script);
RunProcess(startInfo);
}
private static string EscapeAppleScript(string value)
{
// In AppleScript string literals, escape backslash and double-quote
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
}
}
#endregion
#region Linux — notify-send / gdbus
/// <summary>
/// Linux notification using <c>notify-send</c> (libnotify CLI).
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
/// </summary>
private static class LinuxNotification
{
public static void Send(string title, string message, string? folderPath)
{
if (!string.IsNullOrEmpty(folderPath))
{
ShowWithAction(title, message, folderPath);
}
else
{
ShowSimple(title, message);
}
}
private static void ShowSimple(string title, string message)
{
// Try notify-send first
if (TryNotifySend(title, message))
return;
// Fallback to gdbus
TryGdbus(title, message);
}
private static void ShowWithAction(string title, string message, string folderPath)
{
// Run on a background thread because notify-send with --action blocks
// until the notification is dismissed or the action is clicked.
System.Threading.Tasks.Task.Run(() =>
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message);
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (output == "open_folder")
{
var openInfo = new ProcessStartInfo
{
FileName = "xdg-open",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
openInfo.ArgumentList.Add(folderPath);
RunProcess(openInfo);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
// Fallback: show without action
ShowSimple(title, message);
}
});
}
private static bool TryNotifySend(string title, string message)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message);
RunProcess(startInfo);
return true;
}
catch
{
return false;
}
}
private static void TryGdbus(string title, string message)
{
try
{
// Direct D-Bus call to org.freedesktop.Notifications
var startInfo = new ProcessStartInfo
{
FileName = "gdbus",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("call");
startInfo.ArgumentList.Add("--session");
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
startInfo.ArgumentList.Add("0"); // replaces_id
startInfo.ArgumentList.Add(""); // app_icon
startInfo.ArgumentList.Add(title); // summary
startInfo.ArgumentList.Add(message); // body
startInfo.ArgumentList.Add("[]"); // actions
startInfo.ArgumentList.Add("{}"); // hints
startInfo.ArgumentList.Add("-1"); // expire_timeout
RunProcess(startInfo);
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
}
}
}
#endregion
#region Shared utility
/// <summary>
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
/// </summary>
private static void RunProcess(ProcessStartInfo startInfo)
{
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
}
#endregion
}
+129
View File
@@ -0,0 +1,129 @@
using System;
namespace ADOFAIRenderer.Util.OS;
internal static class RegistryUtil
{
private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType;
private static readonly Type? RegistryViewType;
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;
static RegistryUtil()
{
try
{
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
{
var mscorlib = typeof(object).Assembly;
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
}
if (RegistryKeyType != null)
{
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
}
}
catch
{
// ignored
}
}
private static void CloseKey(object? key)
{
if (key == null || RegistryKeyType == null) return;
try
{
if (key is IDisposable disposable)
{
disposable.Dispose();
}
else
{
var closeMethod = RegistryKeyType.GetMethod("Close");
closeMethod?.Invoke(key, null);
}
}
catch
{
// ignored
}
}
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
CloseKey(key);
CloseKey(baseKey);
return subkeyNames;
}
catch
{
return null;
}
}
// ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
CloseKey(key);
CloseKey(baseKey);
return value;
}
catch
{
return null;
}
}
}