feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Notify.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// 沿父进程上溯,跳过 shell/运行时,找到真正的调用方 App(编辑器/终端)
|
||||
/// </summary>
|
||||
internal static partial class ProcessTree
|
||||
{
|
||||
// 这些进程是 shell / 运行时 / 包装器,不是用户面对的 App,跳过继续上溯
|
||||
private static readonly HashSet<string> SkipNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"cmd", "powershell", "pwsh", "bash", "sh", "zsh", "fish",
|
||||
"wsl", "wslhost", "conhost", "openconsole",
|
||||
"node", "deno", "bun", "python", "python3", "py",
|
||||
"uv", "uvx", "npm", "npx", "yarn", "pnpm",
|
||||
"claude", "dotnet", "git", "env", "busybox", "winpty", "sudo",
|
||||
"notify",
|
||||
};
|
||||
|
||||
public static string FindCallerExePath()
|
||||
{
|
||||
try
|
||||
{
|
||||
var parents = BuildParentMap();
|
||||
var pid = GetCurrentProcessId();
|
||||
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
if (!parents.TryGetValue(pid, out var info))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
pid = info.Parent;
|
||||
if (pid == 0 || !parents.TryGetValue(pid, out var anc))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var name = anc.Name;
|
||||
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name[..^4];
|
||||
}
|
||||
|
||||
if (SkipNames.Contains(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 第一个非 shell/运行时的祖先即调用方 App
|
||||
return GetFullPath(pid);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 取不到就回退默认图标
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static Dictionary<uint, (uint Parent, string Name)> BuildParentMap()
|
||||
{
|
||||
var map = new Dictionary<uint, (uint, string)>();
|
||||
var snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1))
|
||||
{
|
||||
return map;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entry = default(PROCESSENTRY32W);
|
||||
entry.dwSize = (uint)Marshal.SizeOf<PROCESSENTRY32W>();
|
||||
|
||||
if (Process32FirstW(snapshot, ref entry))
|
||||
{
|
||||
do
|
||||
{
|
||||
map[entry.th32ProcessID] = (entry.th32ParentProcessID, ReadExeName(ref entry));
|
||||
}
|
||||
while (Process32NextW(snapshot, ref entry));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(snapshot);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static unsafe string ReadExeName(ref PROCESSENTRY32W entry)
|
||||
{
|
||||
fixed (char* p = entry.szExeFile)
|
||||
{
|
||||
return new string(p);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFullPath(uint pid)
|
||||
{
|
||||
var h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
if (h == IntPtr.Zero)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var buf = new char[1024];
|
||||
var size = (uint)buf.Length;
|
||||
return QueryFullProcessImageName(h, 0, ref buf[0], ref size) ? new string(buf, 0, (int)size) : "";
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(h);
|
||||
}
|
||||
}
|
||||
|
||||
private const uint TH32CS_SNAPPROCESS = 0x00000002;
|
||||
private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial uint GetCurrentProcessId();
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool Process32FirstW(IntPtr hSnapshot, ref PROCESSENTRY32W lppe);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool Process32NextW(IntPtr hSnapshot, ref PROCESSENTRY32W lppe);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial IntPtr OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessId);
|
||||
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", StringMarshalling = StringMarshalling.Utf16)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool QueryFullProcessImageName(IntPtr hProcess, uint dwFlags, ref char lpExeName, ref uint lpdwSize);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal unsafe struct PROCESSENTRY32W
|
||||
{
|
||||
public uint dwSize;
|
||||
public uint cntUsage;
|
||||
public uint th32ProcessID;
|
||||
public nint th32DefaultHeapID;
|
||||
public uint th32ModuleID;
|
||||
public uint cntThreads;
|
||||
public uint th32ParentProcessID;
|
||||
public int pcPriClassBase;
|
||||
public uint dwFlags;
|
||||
public fixed char szExeFile[260];
|
||||
}
|
||||
Reference in New Issue
Block a user