SHA256
Compare commits
7
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
bd24152a2e
|
||
|
|
7aa8ae4b54
|
||
|
|
dee3b2dbcf
|
||
|
|
7eec3b4660
|
||
|
|
ce70d89cdb
|
||
|
|
7db1905906
|
||
|
|
bfc79ee4d3
|
No files matched your search
+8
-3
@@ -32,9 +32,11 @@ Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
## Agent Metadata
|
||||
.antigravitycli/
|
||||
.agent/
|
||||
GEMINI.md
|
||||
/.antigravitycli/
|
||||
/.agent/
|
||||
/GEMINI.md
|
||||
/.mcp.json
|
||||
/CLAUDE.md
|
||||
|
||||
# Decompiled Game Code
|
||||
*.cs.txt
|
||||
@@ -43,3 +45,6 @@ GEMINI.md
|
||||
/AsyncInputAutoplayFix.md
|
||||
/AudioMixerExplanation.md
|
||||
/O5Theme.md
|
||||
|
||||
# Developer Utility Files
|
||||
/REVIEW_CHECKLIST.md
|
||||
+118
-24
@@ -14,6 +14,7 @@ using ADOFAIRenderer.UI.Window;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.Audio;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using FFmpeg.AutoGen;
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
@@ -91,7 +92,50 @@ internal class RenderManager : MonoBehaviour
|
||||
public long frameCount;
|
||||
public bool skipPressToStart;
|
||||
public string levelPath = "";
|
||||
public bool IsRenderCancelled { get; private set; }
|
||||
private readonly object _abortStateLock = new();
|
||||
private volatile bool _isRenderCancelled;
|
||||
private volatile bool _renderFailed;
|
||||
private volatile string? _failureReason;
|
||||
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public bool IsRenderCancelled => _isRenderCancelled;
|
||||
public bool IsRenderFailed => _renderFailed;
|
||||
|
||||
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
|
||||
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
||||
|
||||
private void MarkCancelled()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
if (!_renderFailed) _isRenderCancelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The first failure reason wins; ignored once the render is already cancelled or failed.
|
||||
private void MarkFailed(string reason)
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
if (_isRenderCancelled || _renderFailed) return;
|
||||
_failureReason = reason;
|
||||
_renderFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAbortState()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
_isRenderCancelled = false;
|
||||
_renderFailed = false;
|
||||
_failureReason = null;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildAbortMessage() => IsRenderFailed
|
||||
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
||||
: "Rendering was cancelled by user.";
|
||||
public RenderConfig Configuration = null!;
|
||||
|
||||
// Render Sessions State (OOP Instance Fields)
|
||||
@@ -102,7 +146,7 @@ internal class RenderManager : MonoBehaviour
|
||||
public int frameCountBase;
|
||||
|
||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
||||
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||||
|
||||
private FrameReader? _frameReader;
|
||||
private FrameHookPipeline? _frameHookPipeline;
|
||||
@@ -151,6 +195,7 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
public long frameCountForVideo;
|
||||
public long lastVideoFrameIndex = -1;
|
||||
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||||
public long lastRequestedVideoFrameIndex = -1;
|
||||
public int readbackFailureCount;
|
||||
public int throttleCount;
|
||||
@@ -170,8 +215,8 @@ internal class RenderManager : MonoBehaviour
|
||||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
||||
public Thread? RenderThread;
|
||||
public long passCount;
|
||||
public bool endRequested;
|
||||
public bool endSuccessful;
|
||||
public volatile bool endRequested;
|
||||
public volatile bool endSuccessful;
|
||||
public long endFrame = -1;
|
||||
|
||||
// Private Instance Fields
|
||||
@@ -195,12 +240,15 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
public void ResetRenderState(bool success)
|
||||
{
|
||||
IsRenderCancelled = !success;
|
||||
if (success) _isRenderCancelled = false;
|
||||
else MarkCancelled();
|
||||
stage = RenderStage.None;
|
||||
}
|
||||
|
||||
public void CleanUpActiveRender()
|
||||
{
|
||||
if (stage != RenderStage.None)
|
||||
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
|
||||
ResetRenderState(success: false);
|
||||
|
||||
AudioSpectrumGenerator.Clear();
|
||||
@@ -283,8 +331,15 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
if (Instance.stage < RenderStage.OnScene) return;
|
||||
if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true })
|
||||
{
|
||||
MarkFailed("the render thread terminated unexpectedly.");
|
||||
CancelRender();
|
||||
return;
|
||||
}
|
||||
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
||||
{
|
||||
MarkFailed("the level failed or the game controller was lost during rendering.");
|
||||
ResetRenderState(success: false);
|
||||
StopFFmpeg(false);
|
||||
EndRender();
|
||||
@@ -333,6 +388,12 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
while (Instance.stage >= RenderStage.Render)
|
||||
{
|
||||
if (RenderThread is not { IsAlive: true })
|
||||
{
|
||||
isThrottling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
int count;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
@@ -362,7 +423,7 @@ internal class RenderManager : MonoBehaviour
|
||||
if (videoFrameIndex > lastVideoFrameIndex)
|
||||
{
|
||||
lastVideoFrameIndex = videoFrameIndex;
|
||||
lastRequestedVideoFrameIndex = videoFrameIndex;
|
||||
Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex);
|
||||
|
||||
var targetTexture = texture;
|
||||
if (HasActiveFrameHooks && _frameHookPipeline != null)
|
||||
@@ -403,7 +464,6 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
Application.wantsToQuit -= quitDelegate;
|
||||
RenderConfigWindow.CreateWindow();
|
||||
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -440,6 +500,9 @@ internal class RenderManager : MonoBehaviour
|
||||
yield return new WaitForSecondsRealtime(1);
|
||||
}
|
||||
|
||||
// Cancelled or failed during the countdown: do not start the encoder
|
||||
if (stage != RenderStage.OnScene) yield break;
|
||||
|
||||
frameCount = 0; framesSent = 0;
|
||||
_renderStopwatch.Restart();
|
||||
|
||||
@@ -462,13 +525,20 @@ internal class RenderManager : MonoBehaviour
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
||||
ResetFrameHookState();
|
||||
|
||||
// Release the encoder/thread and restore time, input offset, stage and hooks before showing the dialog
|
||||
MarkFailed($"failed to initialize the video encoder: {ex.Message}");
|
||||
ResetRenderState(success: false);
|
||||
StopFFmpeg(false);
|
||||
RestoreRenderEnvironment();
|
||||
|
||||
CommonWarningDialogUI.ShowDialog(
|
||||
"Encoder Error",
|
||||
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
||||
new DialogButton("OK", () =>
|
||||
{
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
})
|
||||
);
|
||||
yield break;
|
||||
@@ -511,10 +581,10 @@ internal class RenderManager : MonoBehaviour
|
||||
ImageQueue.Clear();
|
||||
}
|
||||
passCount = 0;
|
||||
IsRenderCancelled = false;
|
||||
ClearAbortState();
|
||||
frameCountForVideo = 0;
|
||||
lastVideoFrameIndex = -1;
|
||||
lastRequestedVideoFrameIndex = -1;
|
||||
Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
|
||||
endRequested = false;
|
||||
endSuccessful = false;
|
||||
FrameAvailableEvent.Reset();
|
||||
@@ -632,14 +702,14 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
if (Configuration.AudioConfig.RenderAudio)
|
||||
if (Configuration.AudioConfig.RenderAudio && SyncCalculator is { } syncCalculator)
|
||||
{
|
||||
var frameIdx = (int)(passCount - 1);
|
||||
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
||||
var frameTime = frameIdx / effectiveVideoFps;
|
||||
|
||||
var channels = Configuration.AudioConfig.AudioChannels;
|
||||
var samplesNeeded = SyncCalculator.GetNextFrameSamples();
|
||||
var samplesNeeded = syncCalculator.GetNextFrameSamples();
|
||||
|
||||
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
||||
try
|
||||
@@ -655,17 +725,17 @@ internal class RenderManager : MonoBehaviour
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRenderCancelled) break;
|
||||
if (IsRenderAborted) break;
|
||||
|
||||
if (endRequested)
|
||||
{
|
||||
if (passCount > lastRequestedVideoFrameIndex) break;
|
||||
if (passCount > Volatile.Read(ref lastRequestedVideoFrameIndex)) break;
|
||||
}
|
||||
|
||||
FrameAvailableEvent.WaitOne(50);
|
||||
}
|
||||
|
||||
if (!IsRenderCancelled) videoFFmpeg.End();
|
||||
if (!IsRenderAborted) videoFFmpeg.End();
|
||||
videoFFmpeg.Dispose();
|
||||
|
||||
lock (_allVideoFFmpegInstances)
|
||||
@@ -674,12 +744,22 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
_activeVideoFFmpeg = null;
|
||||
endSuccessful = !IsRenderCancelled;
|
||||
endSuccessful = !IsRenderAborted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
||||
|
||||
MarkFailed("encoder thread error. See the log for details.");
|
||||
try
|
||||
{
|
||||
videoFFmpeg.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}");
|
||||
}
|
||||
|
||||
lock (_allVideoFFmpegInstances)
|
||||
{
|
||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||
@@ -708,7 +788,7 @@ internal class RenderManager : MonoBehaviour
|
||||
endRequested = true;
|
||||
if (!success)
|
||||
{
|
||||
IsRenderCancelled = true;
|
||||
MarkCancelled();
|
||||
}
|
||||
FrameAvailableEvent.Set();
|
||||
skipPressToStart = false;
|
||||
@@ -722,7 +802,8 @@ internal class RenderManager : MonoBehaviour
|
||||
if (success) StartCoroutine(StopRenderCoroutine());
|
||||
else
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
|
||||
var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED";
|
||||
Melon<RenderMod>.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources.");
|
||||
|
||||
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
|
||||
if (RenderThread is { IsAlive: true })
|
||||
@@ -796,15 +877,20 @@ internal class RenderManager : MonoBehaviour
|
||||
CurrentOutPath = null;
|
||||
}
|
||||
|
||||
var result = BuildResult(false,
|
||||
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
|
||||
var result = BuildResult(false, BuildAbortMessage());
|
||||
TriggerOnComplete(result);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator StopRenderCoroutine()
|
||||
{
|
||||
yield return new WaitUntil(() => endSuccessful);
|
||||
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
|
||||
if (!endSuccessful)
|
||||
{
|
||||
MarkFailed("the render thread terminated unexpectedly.");
|
||||
CancelRender();
|
||||
yield break;
|
||||
}
|
||||
|
||||
var result = BuildResult(success: true);
|
||||
TriggerOnComplete(result);
|
||||
@@ -813,6 +899,15 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
private void EndRender()
|
||||
{
|
||||
RestoreRenderEnvironment();
|
||||
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
|
||||
private void RestoreRenderEnvironment()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
Time.captureDeltaTime = 0f;
|
||||
@@ -847,12 +942,11 @@ internal class RenderManager : MonoBehaviour
|
||||
_frameReader?.Dispose();
|
||||
ResetFrameHookState();
|
||||
AudioMixer?.Reset(); AudioMixer = null;
|
||||
SyncCalculator.Reset();
|
||||
SyncCalculator = null;
|
||||
|
||||
AudioSpectrumGenerator.Clear();
|
||||
RenderContext.Reset();
|
||||
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
||||
|
||||
@@ -7,7 +7,7 @@ using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.Core.Data;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Localization;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using MelonLoader;
|
||||
using O5Kit.Control;
|
||||
using O5Kit.Core;
|
||||
@@ -840,6 +840,10 @@ internal static class RenderConfigWindow
|
||||
{
|
||||
if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value;
|
||||
if (_videoFPSSlider != null) target.VideoFPS = _videoFPSSlider.Value;
|
||||
|
||||
target.RenderFPS = AlignNTSCFramerate(target.RenderFPS);
|
||||
target.VideoFPS = AlignNTSCFramerate(target.VideoFPS);
|
||||
|
||||
if (_renderWidthSlider != null) target.RenderWidth = (int)Math.Round(_renderWidthSlider.Value);
|
||||
if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value);
|
||||
if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value;
|
||||
@@ -895,6 +899,20 @@ internal static class RenderConfigWindow
|
||||
target.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null)
|
||||
? _windPlanetPicker.Value
|
||||
: null;
|
||||
|
||||
return;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
double AlignNTSCFramerate(double framerate)
|
||||
{
|
||||
const double tolerance = 0.001;
|
||||
|
||||
if (Math.Abs(framerate - 29.97) < tolerance) framerate = 30000.0 / 1001;
|
||||
else if (Math.Abs(framerate - 59.94) < tolerance) framerate = 60000.0 / 1001;
|
||||
else if (Math.Abs(framerate - 23.976) < tolerance || Math.Abs(framerate - 23.98) < tolerance) framerate = 24000.0 / 1001;
|
||||
|
||||
return framerate;
|
||||
}
|
||||
}
|
||||
|
||||
private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom");
|
||||
@@ -1527,10 +1545,10 @@ internal static class RenderConfigWindow
|
||||
Action<bool>? onChanged = null,
|
||||
bool generateLabel = true)
|
||||
{
|
||||
if (generateLabel && !string.IsNullOrEmpty(labelKey))
|
||||
if (generateLabel)
|
||||
{
|
||||
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||
label.text = LocalizationManager.Get(labelKey);
|
||||
label.text = string.IsNullOrEmpty(labelKey) ? "" : LocalizationManager.Get(labelKey);
|
||||
label.color = ctx.Theme.TextDim;
|
||||
LocalizedText.Bind(label, labelKey);
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ internal static class AudioSpectrumGenerator
|
||||
return;
|
||||
}
|
||||
|
||||
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
|
||||
var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
|
||||
|
||||
lock (CachedSpectrums)
|
||||
{
|
||||
|
||||
@@ -15,11 +15,6 @@ internal class AudioSyncCalculator
|
||||
{
|
||||
_sampleRate = sampleRate;
|
||||
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
||||
|
||||
// NTSC
|
||||
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
|
||||
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
|
||||
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
|
||||
@@ -437,16 +437,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// framerate: Framerate value giving hints to the encoder (reciprocal of time_base).
|
||||
//
|
||||
// RationalUtil: Converts FPS (double) into fractional form (numerator/denominator).
|
||||
// e.g., 29.97 → 30000/1001 (NTSC standard)
|
||||
// e.g., 60.00 -> 60/1
|
||||
//
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
||||
var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS);
|
||||
|
||||
// NTSC
|
||||
if (Math.Abs(_settings.FPS - 29.97) < 0.001) fpsRational = (30000, 1001);
|
||||
if (Math.Abs(_settings.FPS - 59.94) < 0.001) fpsRational = (60000, 1001);
|
||||
if (Math.Abs(_settings.FPS - 23.976) < 0.001 || Math.Abs(_settings.FPS - 23.98) < 0.001) fpsRational = (24000, 1001);
|
||||
|
||||
_pCodecContext->time_base = new AVRational
|
||||
{ num = fpsRational.Denominator, den = fpsRational.Numerator };
|
||||
_pCodecContext->framerate = new AVRational
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
|
||||
{
|
||||
RecycleState(state);
|
||||
return;
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -121,7 +122,7 @@ internal static class GPUUtil
|
||||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
||||
|
||||
var nvidiaVerInt = sub % 10 * 10000 + build;
|
||||
_cachedNvidiaVersion = nvidiaVerInt / 100f;
|
||||
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
|
||||
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
||||
return _cachedNvidiaVersion;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util;
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
/// <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
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user