Files
chuan 5ce2c8a982 feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows
Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
2026-06-22 18:05:15 +08:00

168 lines
5.0 KiB
C#

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];
}