feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)

为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows
Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
2026-06-22 18:05:15 +08:00
Unverified
commit 02354bfd2a
53 changed files with 3889 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"name": "claude-code-notify",
"description": "Native Windows toast notifications for Claude Code",
"owner": {
"name": "chuan"
},
"plugins": [
{
"name": "claude-code-notify",
"description": "Native Windows toast notifications for Claude Code (Avalonia/.NET rewrite)",
"version": "1.0.0",
"source": "./",
"author": {
"name": "chuan"
}
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "claude-code-notify",
"description": "Native Windows toast notifications for Claude Code (Avalonia/.NET rewrite)",
"version": "1.0.0",
"author": {
"name": "chuan"
},
"license": "MIT"
}
+23
View File
@@ -0,0 +1,23 @@
root = true
[*]
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.xml]
indent_size = 2
[*.props]
indent_size = 2
[*.csproj]
indent_size = 2
[*.targets]
indent_size = 2
[*.cs]
csharp_style_namespace_declarations = file_scoped:warning
+5
View File
@@ -0,0 +1,5 @@
# 批处理脚本必须用 CRLF,否则 cmd 解析会出错
*.bat text eol=crlf
*.cmd text eol=crlf
# shell 脚本必须用 LF
*.sh text eol=lf
+477
View File
@@ -0,0 +1,477 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
+7
View File
@@ -0,0 +1,7 @@
<Project >
<ItemGroup>
<!--
todo: 编译与发布相关配置
-->
</ItemGroup>
</Project>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Simscop
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Notify.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<StyleInclude Source="avares://Semi.Avalonia/Index.axaml" />
<StyleInclude Source="avares://Ursa.Themes.Semi/Index.axaml" />
</Application.Styles>
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/claude.ico"
ToolTipText="Claude Code Notify"
Clicked="OnTrayClicked">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="退出" Click="OnExitClick" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
</Application>
+121
View File
@@ -0,0 +1,121 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Notify.Ipc;
using Notify.Models;
using Notify.Services;
using Notify.ViewModels;
using Notify.Views;
namespace Notify;
public partial class App : Application
{
private SettingsWindow? _settingsWindow;
private SpoolWatcher? _spoolWatcher;
public static new App Current => (App)Application.Current!;
public SettingsService Settings { get; } = new();
public ToastManager Toasts { get; private set; } = null!;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
Settings.Load();
Toasts = new ToastManager(Settings);
// 监视 spool 目录,把 CLI 投递的请求转成 toast
_spoolWatcher = new SpoolWatcher(OnNotify);
_spoolWatcher.Start();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// 无主窗口的常驻进程:仅托盘存在,靠托盘菜单或外部请求驱动
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
// --demo:启动即弹一条 toast 并打开设置,便于无托盘交互地验证
if (desktop.Args is { Length: > 0 } args && System.Array.IndexOf(args, "--demo") >= 0)
{
Dispatcher.UIThread.Post(RunDemo);
}
}
base.OnFrameworkInitializationCompleted();
}
// 监视线程收到请求,切回 UI 线程弹出 toast
private void OnNotify(NotifyMessage message)
{
if (Settings.Current.PlaySound)
{
Notify.Interop.Sound.Play();
}
// 目标窗口已是前台(你正盯着看):完成类通知弹一下即可,用更短的停留
int? durationOverride = null;
if (!message.InputMode && message.TargetHwnd != 0 &&
Notify.Interop.Win32.GetForegroundWindow().ToInt64() == message.TargetHwnd)
{
durationOverride = Settings.Current.FocusedDurationSeconds;
}
Dispatcher.UIThread.Post(() => Toasts.Show(new ToastRequest
{
Title = message.Title,
Message = message.Message,
InputMode = message.InputMode,
Sticky = message.Sticky,
TargetHwnd = message.TargetHwnd,
WtRuntimeId = message.WtRuntimeId,
IconPath = message.IconPath,
DurationSecondsOverride = durationOverride,
}));
}
private void RunDemo()
{
// 普通:会自动消失
Toasts.Show(new ToastRequest { Title = "Claude Code", Message = "任务已完成 — 4 秒后自动消失" });
// 常驻:InputMode 且 Sticky,不点不消失
Toasts.Show(new ToastRequest
{
Title = "需要你的输入",
Message = "权限请求 — 常驻,点击 / ✕ 才关闭",
InputMode = true,
Sticky = true,
});
}
// 左键单击托盘图标直接打开设置
private void OnTrayClicked(object? sender, EventArgs e) => OpenSettings();
private void OpenSettings()
{
if (_settingsWindow is { } w)
{
w.Activate();
return;
}
_settingsWindow = new SettingsWindow
{
DataContext = new SettingsViewModel(Settings, Toasts),
};
_settingsWindow.Closed += (_, _) => _settingsWindow = null;
_settingsWindow.Show();
}
private void OnExitClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.
+185
View File
@@ -0,0 +1,185 @@
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using Notify.Interop;
using Notify.Ipc;
using Notify.Models;
using Notify.Serialization;
using Notify.Services;
namespace Notify.Cli;
/// <summary>
/// 钩子子命令实现:纯互操作 / IPC,绝不加载 Avalonia,做完即退出
/// </summary>
public static class CliRunner
{
// UserPromptSubmit:记录前台窗口与 prompt
public static int Save()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
var hwnd = Win32.GetForegroundWindow();
// 前台是 Windows Terminal 时,记录当前标签的 RuntimeId
var wtRuntimeId = WinTerminalTabs.IsWindowsTerminal(hwnd)
? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd)
: "";
StateStore.Save(input.SessionId, new StateData
{
Hwnd = hwnd.ToInt64(),
Prompt = input.Prompt ?? "",
WtRuntimeId = wtRuntimeId,
CallerExePath = ProcessTree.FindCallerExePath(),
});
return 0;
}
// Stop:任务完成通知,正文取本次 prompt
public static int Notify()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
var state = StateStore.Load(input.SessionId);
var message = !string.IsNullOrWhiteSpace(state?.Prompt) ? state!.Prompt : "Task completed";
NotificationSpool.Deliver(new NotifyMessage
{
SessionId = input.SessionId,
Title = "Claude Code",
Message = Sanitize(message),
InputMode = false,
Sticky = false,
TargetHwnd = state?.Hwnd ?? 0,
WtRuntimeId = state?.WtRuntimeId,
IconPath = state?.CallerExePath,
});
return 0;
}
// Notification / PreToolUse:需要输入,常驻显示
public static int Input()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
// 过滤无需打扰的类型
if (input.NotificationType is "auth_success" or "elicitation_complete" or "elicitation_response")
{
return 0;
}
var (title, message) = Resolve(input);
var state = StateStore.Load(input.SessionId);
NotificationSpool.Deliver(new NotifyMessage
{
SessionId = input.SessionId,
Title = title,
Message = Sanitize(message),
InputMode = true,
Sticky = true,
TargetHwnd = state?.Hwnd ?? 0,
WtRuntimeId = state?.WtRuntimeId,
IconPath = state?.CallerExePath,
});
return 0;
}
// SessionEnd:清理会话状态
public static int Cleanup()
{
var input = ReadStdin();
if (!string.IsNullOrEmpty(input?.SessionId))
{
StateStore.Delete(input.SessionId);
}
return 0;
}
// 按 tool_name / notification_type 决定标题与正文,对齐原版语义
private static (string Title, string Message) Resolve(HookInput input)
{
if (input.ToolName == "AskUserQuestion")
{
var msg = string.IsNullOrEmpty(input.Message) ? "Claude 在向你提问" : input.Message!;
return ("Claude is Asking", msg);
}
if (input.ToolName == "ExitPlanMode")
{
return ("Plan Ready for Approval", "Claude 提交了一份计划,待批准");
}
var title = input.NotificationType switch
{
"permission_prompt" => "Permission Required",
"idle_prompt" => "Claude is Waiting",
"elicitation_dialog" => "MCP Asks",
_ => "Input Required",
};
var message = string.IsNullOrEmpty(input.Message) ? "Claude needs your input" : input.Message!;
return (title, message);
}
// 折叠换行/制表/多余空白为单行,避免撑乱 toast 布局(截断交给 toast 的省略号)
private static string Sanitize(string s)
{
if (string.IsNullOrEmpty(s))
{
return s;
}
s = s.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
while (s.Contains(" "))
{
s = s.Replace(" ", " ");
}
return s.Trim();
}
// 直接读原始字节并按 UTF-8 解码:WinExe 下 Console.In 不可靠,且其代码页
// 会把中文解成乱码(GBK),这里绕开
private static HookInput? ReadStdin()
{
try
{
using var stdin = Console.OpenStandardInput();
using var ms = new MemoryStream();
stdin.CopyTo(ms);
var bytes = ms.ToArray();
if (bytes.Length == 0)
{
return null;
}
var text = Encoding.UTF8.GetString(bytes).TrimStart('');
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return JsonSerializer.Deserialize(text, AppJsonContext.Default.HookInput);
}
catch
{
return null;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Notify.Cli;
/// <summary>
/// Claude Code 钩子经 stdin 传入的 JSON
/// </summary>
public sealed class HookInput
{
[JsonPropertyName("session_id")]
public string? SessionId { get; set; }
[JsonPropertyName("prompt")]
public string? Prompt { get; set; }
[JsonPropertyName("notification_type")]
public string? NotificationType { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("tool_name")]
public string? ToolName { get; set; }
}
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace Notify.Interop;
/// <summary>
/// 从 exe 提取图标并转成 Avalonia 位图
///
/// ExtractIconEx 拿 HICON,再用 GDI 读出 BGRA 像素构造 Bitmap;不依赖
/// System.Drawing(其 AOT 不友好)
/// </summary>
internal static partial class AppIcon
{
public static Bitmap? Extract(string exePath)
{
if (string.IsNullOrEmpty(exePath))
{
return null;
}
var hIcon = IntPtr.Zero;
try
{
if (ExtractIconExW(exePath, 0, out hIcon, out _, 1) == 0 || hIcon == IntPtr.Zero)
{
return null;
}
return IconToBitmap(hIcon);
}
catch
{
return null;
}
finally
{
if (hIcon != IntPtr.Zero)
{
DestroyIcon(hIcon);
}
}
}
private static Bitmap? IconToBitmap(IntPtr hIcon)
{
if (!GetIconInfo(hIcon, out var ii))
{
return null;
}
try
{
var bm = default(BITMAP);
if (GetObjectW(ii.hbmColor, Marshal.SizeOf<BITMAP>(), ref bm) == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0)
{
return null;
}
var w = bm.bmWidth;
var h = bm.bmHeight;
var buffer = new byte[w * h * 4];
var bmi = new BITMAPINFOHEADER
{
biSize = (uint)Marshal.SizeOf<BITMAPINFOHEADER>(),
biWidth = w,
biHeight = -h, // 负数 = 自上而下,行序正常
biPlanes = 1,
biBitCount = 32,
biCompression = 0,
};
var hdc = GetDC(IntPtr.Zero);
try
{
if (GetDIBits(hdc, ii.hbmColor, 0, (uint)h, buffer, ref bmi, 0) == 0)
{
return null;
}
}
finally
{
ReleaseDC(IntPtr.Zero, hdc);
}
// 某些老图标无 alpha 通道(全 0),那样会整块透明,补成不透明
var anyAlpha = false;
for (var i = 3; i < buffer.Length; i += 4)
{
if (buffer[i] != 0)
{
anyAlpha = true;
break;
}
}
if (!anyAlpha)
{
for (var i = 3; i < buffer.Length; i += 4)
{
buffer[i] = 255;
}
}
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
return new Bitmap(
PixelFormat.Bgra8888,
AlphaFormat.Unpremul,
handle.AddrOfPinnedObject(),
new PixelSize(w, h),
new Vector(96, 96),
w * 4);
}
finally
{
handle.Free();
}
}
finally
{
if (ii.hbmColor != IntPtr.Zero)
{
DeleteObject(ii.hbmColor);
}
if (ii.hbmMask != IntPtr.Zero)
{
DeleteObject(ii.hbmMask);
}
}
}
[LibraryImport("shell32.dll", EntryPoint = "ExtractIconExW", StringMarshalling = StringMarshalling.Utf16)]
private static partial uint ExtractIconExW(string lpszFile, int nIconIndex, out IntPtr phiconLarge, out IntPtr phiconSmall, uint nIcons);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool DestroyIcon(IntPtr hIcon);
[LibraryImport("gdi32.dll", EntryPoint = "GetObjectW")]
private static partial int GetObjectW(IntPtr hgdiobj, int cbBuffer, ref BITMAP lpvObject);
[LibraryImport("gdi32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool DeleteObject(IntPtr hObject);
[LibraryImport("gdi32.dll")]
private static partial int GetDIBits(IntPtr hdc, IntPtr hbmp, uint uStartScan, uint cScanLines, [Out] byte[] lpvBits, ref BITMAPINFOHEADER lpbi, uint uUsage);
[LibraryImport("user32.dll")]
private static partial IntPtr GetDC(IntPtr hWnd);
[LibraryImport("user32.dll")]
private static partial int ReleaseDC(IntPtr hWnd, IntPtr hDC);
}
[StructLayout(LayoutKind.Sequential)]
internal struct ICONINFO
{
public int fIcon;
public uint xHotspot;
public uint yHotspot;
public IntPtr hbmMask;
public IntPtr hbmColor;
}
[StructLayout(LayoutKind.Sequential)]
internal struct BITMAP
{
public int bmType;
public int bmWidth;
public int bmHeight;
public int bmWidthBytes;
public ushort bmPlanes;
public ushort bmBitsPixel;
public IntPtr bmBits;
}
[StructLayout(LayoutKind.Sequential)]
internal struct BITMAPINFOHEADER
{
public uint biSize;
public int biWidth;
public int biHeight;
public ushort biPlanes;
public ushort biBitCount;
public uint biCompression;
public uint biSizeImage;
public int biXPelsPerMeter;
public int biYPelsPerMeter;
public uint biClrUsed;
public uint biClrImportant;
}
+167
View File
@@ -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];
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using Avalonia.Platform;
namespace Notify.Interop;
/// <summary>
/// 播放打包的提示音 wav
///
/// 用 winmm 的 PlaySound 从内存异步播放;为配合 SND_ASYNC,wav 拷到不会被 GC
/// 移动的非托管内存里常驻
/// </summary>
internal static partial class Sound
{
private const uint SND_ASYNC = 0x0001;
private const uint SND_NODEFAULT = 0x0002;
private const uint SND_MEMORY = 0x0004;
private static IntPtr _wavPtr;
private static DateTime _lastPlay = DateTime.MinValue;
[LibraryImport("winmm.dll", EntryPoint = "PlaySoundW")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool PlaySound(IntPtr pszSound, IntPtr hmod, uint fdwSound);
public static void Play()
{
// 防连环音:300ms 内只响一次
var now = DateTime.UtcNow;
if ((now - _lastPlay).TotalMilliseconds < 300)
{
return;
}
_lastPlay = now;
try
{
EnsureLoaded();
if (_wavPtr != IntPtr.Zero)
{
PlaySound(_wavPtr, IntPtr.Zero, SND_MEMORY | SND_ASYNC | SND_NODEFAULT);
}
}
catch
{
// 播放失败无所谓
}
}
private static void EnsureLoaded()
{
if (_wavPtr != IntPtr.Zero)
{
return;
}
using var s = AssetLoader.Open(new Uri("avares://notify/Assets/notification.wav"));
using var ms = new MemoryStream();
s.CopyTo(ms);
var bytes = ms.ToArray();
_wavPtr = Marshal.AllocHGlobal(bytes.Length);
Marshal.Copy(bytes, 0, _wavPtr, bytes.Length);
}
}
+209
View File
@@ -0,0 +1,209 @@
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
namespace Notify.Interop;
/// <summary>
/// 把窗口"钉"到所有虚拟桌面(Win+Tab 切桌面后仍可见)
///
/// 走 Windows **未公开** 的 COM 接口:ImmersiveShell -> IApplicationViewCollection
/// -> IVirtualDesktopPinnedApps.PinView。GUID/方法顺序随 build 变化,这里用 Win11 24H2
/// (build 26100) 的定义(来自 MScholtes/VirtualDesktop),实测在 26200 上 GUID 仍匹配
///
/// 关键点:IApplicationView 是 IInspectable,而现代 .NET 不支持 IInspectable 封送,
/// 因此这里把 view 当作 **裸 IntPtr** 在 GetViewForHwnd / PinView 之间传递,绕开封送
/// 任何一步失败都被吞掉,退回"仅当前桌面显示"
///
/// AOT 说明:接口用源生成 COMGeneratedComInterface),ImmersiveShell 用
/// CoCreateInstance 直接拿 IUnknown 指针并经 StrategyBasedComWrappers 包装,
/// 不再依赖内置 COM 封送(NativeAOT 下内置封送会被裁剪)
/// </summary>
public static partial class VirtualDesktopPinner
{
private static readonly StrategyBasedComWrappers ComWrappers = new();
private static bool _initialized;
private static bool _available;
private static IApplicationViewCollection? _views;
private static IVirtualDesktopPinnedApps? _pinned;
/// <summary>
/// 最近一次失败的诊断信息(临时排查用)
/// </summary>
public static string LastError { get; private set; } = "";
/// <summary>
/// 尝试把指定窗口钉到所有桌面;返回是否成功
/// </summary>
public static bool TryPin(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero)
{
LastError = "hwnd=0";
return false;
}
try
{
EnsureInit();
if (!_available || _views is null || _pinned is null)
{
LastError = "init failed: " + LastError;
return false;
}
var view = IntPtr.Zero;
try
{
_views.GetViewForHwnd(hwnd, out view);
if (view == IntPtr.Zero)
{
LastError = "GetViewForHwnd returned null";
return false;
}
_pinned.PinView(view);
return true;
}
catch (Exception ex)
{
LastError = "pin: " + ex.GetType().Name + " 0x" + ex.HResult.ToString("X8") + " " + ex.Message;
return false;
}
finally
{
if (view != IntPtr.Zero)
{
Marshal.Release(view); // GetViewForHwnd 返回的指针已 AddRef
}
}
}
catch (Exception ex)
{
LastError = "outer: " + ex.GetType().Name + " " + ex.Message;
return false;
}
}
private static void EnsureInit()
{
if (_initialized)
{
return;
}
_initialized = true;
var shellPtr = IntPtr.Zero;
try
{
// 用 CoCreateInstance 直接拿 ImmersiveShell 的 IServiceProvider10 指针
// 等价于经典写法 Activator.CreateInstance(GetTypeFromCLSID(...)) 但 AOT 友好
var clsid = CLSID_ImmersiveShell;
var iidServiceProvider = IID_IServiceProvider10;
// ImmersiveShell 是本地服务器,必须用 CLSCTX_LOCAL_SERVER
// 仅传 INPROC_SERVER 会得到 0x80040154 REGDB_E_CLASSNOTREG
var hr = CoCreateInstance(ref clsid, IntPtr.Zero, CLSCTX_LOCAL_SERVER, ref iidServiceProvider, out shellPtr);
if (hr < 0 || shellPtr == IntPtr.Zero)
{
throw new InvalidOperationException("CoCreateInstance(ImmersiveShell) 0x" + hr.ToString("X8"));
}
var shell = (IServiceProvider10)ComWrappers.GetOrCreateObjectForComInstance(shellPtr, CreateObjectFlags.None);
var viewCollectionGuid = IID_IApplicationViewCollection;
var viewsPtr = shell.QueryService(ref viewCollectionGuid, ref viewCollectionGuid);
_views = WrapRequired<IApplicationViewCollection>(viewsPtr, "IApplicationViewCollection");
var pinnedGuid = IID_IVirtualDesktopPinnedApps;
var clsidPinned = CLSID_VirtualDesktopPinnedApps;
var pinnedPtr = shell.QueryService(ref clsidPinned, ref pinnedGuid);
_pinned = WrapRequired<IVirtualDesktopPinnedApps>(pinnedPtr, "IVirtualDesktopPinnedApps");
_available = true;
}
catch (Exception ex)
{
_available = false;
LastError = "EnsureInit: " + ex.GetType().Name + " 0x" + ex.HResult.ToString("X8") + " " + ex.Message;
}
finally
{
if (shellPtr != IntPtr.Zero)
{
// GetOrCreateObjectForComInstance 持有了自己的引用,释放本地这一份
Marshal.Release(shellPtr);
}
}
}
// 把 QueryService 返回的裸 IUnknown 指针包装成托管 RCW,并释放本地引用
private static T WrapRequired<T>(IntPtr unknown, string name)
{
if (unknown == IntPtr.Zero)
{
throw new InvalidOperationException("QueryService(" + name + ") 返回 null");
}
try
{
return (T)ComWrappers.GetOrCreateObjectForComInstance(unknown, CreateObjectFlags.None);
}
finally
{
Marshal.Release(unknown);
}
}
private const int CLSCTX_LOCAL_SERVER = 4;
// --- CLSIDs / IIDs ---
private static readonly Guid CLSID_ImmersiveShell = new("C2F03A33-21F5-47FA-B4BB-156362A2F239");
private static readonly Guid CLSID_VirtualDesktopPinnedApps = new("B5A399E7-1C87-46B8-88E9-FC5747B171BD");
private static readonly Guid IID_IServiceProvider10 = new("6D5140C1-7436-11CE-8034-00AA006009FA");
private static readonly Guid IID_IApplicationViewCollection = new("1841C6D7-4F9D-42C0-AF41-8747538F10E5");
private static readonly Guid IID_IVirtualDesktopPinnedApps = new("4CE81583-1E4C-4632-A621-07A53543148F");
[LibraryImport("ole32.dll")]
private static partial int CoCreateInstance(
ref Guid rclsid,
IntPtr pUnkOuter,
int dwClsContext,
ref Guid riid,
out IntPtr ppv);
}
// ImmersiveShell 的 IServiceProvider(与系统 IServiceProvider 不同)
// QueryService 返回裸 IUnknown 指针(nint),由调用方用 ComWrappers 包装
[GeneratedComInterface]
[Guid("6D5140C1-7436-11CE-8034-00AA006009FA")]
internal partial interface IServiceProvider10
{
IntPtr QueryService(ref Guid service, ref Guid riid);
}
// 只声明到 GetViewForHwnd(第 4 个方法);view 用 IntPtr,避免 IInspectable 封送
[GeneratedComInterface]
[Guid("1841C6D7-4F9D-42C0-AF41-8747538F10E5")]
internal partial interface IApplicationViewCollection
{
int GetViews(out IntPtr array);
int GetViewsByZOrder(out IntPtr array);
int GetViewsByAppUserModelId([MarshalAs(UnmanagedType.LPWStr)] string id, out IntPtr array);
int GetViewForHwnd(IntPtr hwnd, out IntPtr view);
}
// view 参数同样用 IntPtr
[GeneratedComInterface]
[Guid("4CE81583-1E4C-4632-A621-07A53543148F")]
internal partial interface IVirtualDesktopPinnedApps
{
[return: MarshalAs(UnmanagedType.Bool)]
bool IsAppIdPinned([MarshalAs(UnmanagedType.LPWStr)] string appId);
void PinAppID([MarshalAs(UnmanagedType.LPWStr)] string appId);
void UnpinAppID([MarshalAs(UnmanagedType.LPWStr)] string appId);
[return: MarshalAs(UnmanagedType.Bool)]
bool IsViewPinned(IntPtr applicationView);
void PinView(IntPtr applicationView);
void UnpinView(IntPtr applicationView);
}
+92
View File
@@ -0,0 +1,92 @@
using System;
using System.Runtime.InteropServices;
namespace Notify.Interop;
internal static partial class Win32
{
[LibraryImport("user32.dll")]
internal static partial IntPtr GetForegroundWindow();
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetForegroundWindow(IntPtr hWnd);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool AllowSetForegroundWindow(uint dwProcessId);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
[LibraryImport("user32.dll")]
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool BringWindowToTop(IntPtr hWnd);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool ShowWindow(IntPtr hWnd, int nCmdShow);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool IsIconic(IntPtr hWnd);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool IsWindow(IntPtr hWnd);
[LibraryImport("user32.dll")]
internal static partial void SwitchToThisWindow(IntPtr hWnd, [MarshalAs(UnmanagedType.Bool)] bool fAltTab);
[LibraryImport("user32.dll")]
internal static partial void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo);
[LibraryImport("kernel32.dll")]
internal static partial uint GetCurrentThreadId();
[LibraryImport("user32.dll", EntryPoint = "GetClassNameW", StringMarshalling = StringMarshalling.Utf16)]
internal static partial int GetClassName(IntPtr hWnd, ref char lpClassName, int nMaxCount);
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
internal static partial IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
internal static partial IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
// 把窗口设为工具窗口:从任务栏与 Alt+Tab 中隐藏
internal static void MakeToolWindow(IntPtr hWnd)
{
var ex = GetWindowLongPtr(hWnd, GWL_EXSTYLE).ToInt64();
ex = (ex | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW;
SetWindowLongPtr(hWnd, GWL_EXSTYLE, new IntPtr(ex));
}
// 取窗口类名
internal static string GetClassNameOf(IntPtr hWnd)
{
var buf = new char[256];
var n = GetClassName(hWnd, ref buf[0], buf.Length);
return n > 0 ? new string(buf, 0, n) : "";
}
// --- 常量 ---
internal const uint ASFW_ANY = 0xFFFFFFFF;
internal const int SW_RESTORE = 9;
internal const int SW_SHOW = 5;
internal const uint SWP_NOSIZE = 0x0001;
internal const uint SWP_NOMOVE = 0x0002;
internal const uint SWP_SHOWWINDOW = 0x0040;
internal const byte VK_MENU = 0x12;
internal const uint KEYEVENTF_KEYUP = 0x0002;
internal const int GWL_EXSTYLE = -20;
internal const long WS_EX_TOOLWINDOW = 0x00000080;
internal const long WS_EX_APPWINDOW = 0x00040000;
}
+299
View File
@@ -0,0 +1,299 @@
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
using System.Text;
namespace Notify.Interop;
/// <summary>
/// Windows Terminal 标签页的捕获与切换
///
/// save 时记录当前选中标签的 RuntimeId,点击通知激活窗口后再据此切回该标签
/// 全程走源生成 COMGeneratedComInterface),保证 NativeAOT 兼容
/// vtable 顺序与 GUID 均取自 Windows SDK UIAutomationClient.h
/// </summary>
public static partial class WinTerminalTabs
{
private const string WtClass = "CASCADIA_HOSTING_WINDOW_CLASS";
private const int TreeScopeDescendants = 4;
private const int ControlTypePropertyId = 30003;
private const int IsSelectedPropertyId = 30079;
private const int TabItemControlTypeId = 50019;
private const int SelectionItemPatternId = 10010;
private const int CLSCTX_INPROC_SERVER = 1;
private static readonly StrategyBasedComWrappers ComWrappers = new();
private static IUIAutomation? _uia;
private static bool _initTried;
public static bool IsWindowsTerminal(IntPtr hwnd) =>
hwnd != IntPtr.Zero && Win32.GetClassNameOf(hwnd) == WtClass;
// 返回当前选中标签的 RuntimeId 串,失败返回空串
public static string GetSelectedTabRuntimeId(IntPtr hwnd)
{
try
{
var uia = EnsureUia();
if (uia is null)
{
return "";
}
var root = uia.ElementFromHandle(hwnd);
var cond = uia.CreateTrueCondition();
var all = root.FindAll(TreeScopeDescendants, cond);
var count = all.Length();
for (var i = 0; i < count; i++)
{
var el = all.GetElement(i);
if (GetIntProperty(el, ControlTypePropertyId) != TabItemControlTypeId)
{
continue;
}
if (GetBoolProperty(el, IsSelectedPropertyId))
{
return RuntimeIdOf(el);
}
}
}
catch
{
// 任何 COM 异常退回空串
}
return "";
}
// 在已激活的 WT 窗口里找到匹配 RuntimeId 的标签并选中
public static bool SelectTab(IntPtr hwnd, string runtimeId)
{
if (string.IsNullOrEmpty(runtimeId))
{
return false;
}
try
{
var uia = EnsureUia();
if (uia is null)
{
return false;
}
var root = uia.ElementFromHandle(hwnd);
var cond = uia.CreateTrueCondition();
var all = root.FindAll(TreeScopeDescendants, cond);
var count = all.Length();
for (var i = 0; i < count; i++)
{
var el = all.GetElement(i);
if (GetIntProperty(el, ControlTypePropertyId) != TabItemControlTypeId)
{
continue;
}
if (RuntimeIdOf(el) != runtimeId)
{
continue;
}
var pattern = el.GetCurrentPattern(SelectionItemPatternId);
if (pattern is null)
{
return false;
}
pattern.Select();
return true;
}
}
catch
{
// 切换失败不影响窗口已被激活
}
return false;
}
private static IUIAutomation? EnsureUia()
{
if (_initTried)
{
return _uia;
}
_initTried = true;
try
{
var clsid = new Guid("ff48dba4-60ef-4201-aa87-54103eef594e");
var iid = typeof(IUIAutomation).GUID;
var hr = CoCreateInstance(ref clsid, IntPtr.Zero, CLSCTX_INPROC_SERVER, ref iid, out var ptr);
if (hr >= 0 && ptr != IntPtr.Zero)
{
_uia = (IUIAutomation)ComWrappers.GetOrCreateObjectForComInstance(ptr, CreateObjectFlags.None);
Marshal.Release(ptr);
}
}
catch
{
_uia = null;
}
return _uia;
}
private static int GetIntProperty(IUIAutomationElement el, int propertyId)
{
var v = el.GetCurrentPropertyValue(propertyId);
return v.lVal;
}
private static bool GetBoolProperty(IUIAutomationElement el, int propertyId)
{
var v = el.GetCurrentPropertyValue(propertyId);
return v.boolVal != 0;
}
// RuntimeId 是一个 int 数组(SAFEARRAY),拼成点分串用于比较
private static string RuntimeIdOf(IUIAutomationElement el)
{
var psa = el.GetRuntimeId();
if (psa == IntPtr.Zero)
{
return "";
}
try
{
if (SafeArrayGetLBound(psa, 1, out var lb) < 0 || SafeArrayGetUBound(psa, 1, out var ub) < 0)
{
return "";
}
var sb = new StringBuilder();
for (var idx = lb; idx <= ub; idx++)
{
var i = idx;
if (SafeArrayGetElement(psa, ref i, out var val) < 0)
{
return "";
}
if (sb.Length > 0)
{
sb.Append('.');
}
sb.Append(val);
}
return sb.ToString();
}
finally
{
SafeArrayDestroy(psa);
}
}
[LibraryImport("ole32.dll")]
private static partial int CoCreateInstance(ref Guid rclsid, IntPtr pUnkOuter, int dwClsContext, ref Guid riid, out IntPtr ppv);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetLBound(IntPtr psa, uint nDim, out int plLbound);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetUBound(IntPtr psa, uint nDim, out int plUbound);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetElement(IntPtr psa, ref int rgIndices, out int pv);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayDestroy(IntPtr psa);
}
// VARIANT 的最小化布局(x64 为 24 字节),只读 VT_I4 / VT_BOOL
[StructLayout(LayoutKind.Explicit, Size = 24)]
internal struct VARIANT
{
[FieldOffset(0)]
public ushort vt;
[FieldOffset(8)]
public int lVal;
[FieldOffset(8)]
public short boolVal;
}
[GeneratedComInterface]
[Guid("30cbe57d-d9d0-452a-ab13-7ac5ac4825ee")]
internal partial interface IUIAutomation
{
void _CompareElements();
void _CompareRuntimeIds();
void _GetRootElement();
IUIAutomationElement ElementFromHandle(IntPtr hwnd);
void _ElementFromPoint();
void _GetFocusedElement();
void _GetRootElementBuildCache();
void _ElementFromHandleBuildCache();
void _ElementFromPointBuildCache();
void _GetFocusedElementBuildCache();
void _CreateTreeWalker();
void _get_ControlViewWalker();
void _get_ContentViewWalker();
void _get_RawViewWalker();
void _get_RawViewCondition();
void _get_ControlViewCondition();
void _get_ContentViewCondition();
void _CreateCacheRequest();
IUIAutomationCondition CreateTrueCondition();
}
[GeneratedComInterface]
[Guid("d22108aa-8ac5-49a5-837b-37bbb3d7591e")]
internal partial interface IUIAutomationElement
{
void _SetFocus();
IntPtr GetRuntimeId();
void _FindFirst();
IUIAutomationElementArray FindAll(int scope, IUIAutomationCondition condition);
void _FindFirstBuildCache();
void _FindAllBuildCache();
void _BuildUpdatedCache();
VARIANT GetCurrentPropertyValue(int propertyId);
void _GetCurrentPropertyValueEx();
void _GetCachedPropertyValue();
void _GetCachedPropertyValueEx();
void _GetCurrentPatternAs();
void _GetCachedPatternAs();
[return: MarshalUsing(typeof(UniqueComInterfaceMarshaller<IUIAutomationSelectionItemPattern>))]
IUIAutomationSelectionItemPattern? GetCurrentPattern(int patternId);
}
[GeneratedComInterface]
[Guid("14314595-b4bc-4055-95f2-58f2e42c9855")]
internal partial interface IUIAutomationElementArray
{
int Length();
IUIAutomationElement GetElement(int index);
}
[GeneratedComInterface]
[Guid("352ffba8-0973-437c-a61f-f64cafd81df9")]
internal partial interface IUIAutomationCondition
{
}
[GeneratedComInterface]
[Guid("a8efa66a-0fda-421a-9194-38021f3578ea")]
internal partial interface IUIAutomationSelectionItemPattern
{
void Select();
}
+72
View File
@@ -0,0 +1,72 @@
using System;
namespace Notify.Interop;
/// <summary>
/// 把目标窗口拉回前台
///
/// Windows 限制后台进程抢焦点,这里用一套组合技绕过:ALT 键模拟 +
/// AttachThreadInput 把当前线程与前台/目标线程的输入队列挂接 +
/// SetWindowPos/BringWindowToTop/SwitchToThisWindow/SetForegroundWindow 多管齐下
/// </summary>
public static class WindowActivator
{
public static bool Activate(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero || !Win32.IsWindow(hwnd))
{
return false;
}
try
{
// 最小化的先还原
if (Win32.IsIconic(hwnd))
{
Win32.ShowWindow(hwnd, Win32.SW_RESTORE);
}
var foreground = Win32.GetForegroundWindow();
var curThread = Win32.GetCurrentThreadId();
var fgThread = Win32.GetWindowThreadProcessId(foreground, out _);
var targetThread = Win32.GetWindowThreadProcessId(hwnd, out _);
// 模拟一次 ALT 抬起,满足 Windows 的"防焦点抢占"前置条件
Win32.keybd_event(Win32.VK_MENU, 0, 0, IntPtr.Zero);
Win32.keybd_event(Win32.VK_MENU, 0, Win32.KEYEVENTF_KEYUP, IntPtr.Zero);
if (fgThread != curThread)
{
Win32.AttachThreadInput(curThread, fgThread, true);
}
if (targetThread != curThread && targetThread != fgThread)
{
Win32.AttachThreadInput(curThread, targetThread, true);
}
Win32.AllowSetForegroundWindow(Win32.ASFW_ANY);
Win32.SetWindowPos(hwnd, IntPtr.Zero, 0, 0, 0, 0, Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_SHOWWINDOW);
Win32.BringWindowToTop(hwnd);
Win32.SwitchToThisWindow(hwnd, true);
Win32.SetForegroundWindow(hwnd);
Win32.ShowWindow(hwnd, Win32.SW_SHOW);
if (targetThread != curThread && targetThread != fgThread)
{
Win32.AttachThreadInput(curThread, targetThread, false);
}
if (fgThread != curThread)
{
Win32.AttachThreadInput(curThread, fgThread, false);
}
return Win32.GetForegroundWindow() == hwnd;
}
catch
{
return false;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace Notify.Ipc;
internal static class IpcConstants
{
// 保证 Host 单例的互斥量名(Local 级,按用户会话隔离)
public const string HostMutexName = "ClaudeCodeNotifyHost";
}
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using Notify.Serialization;
namespace Notify.Ipc;
/// <summary>
/// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费
///
/// 取代命名管道,避免 CLI 在 Host 冷启动时被阻塞而拖住 Claude Code
/// </summary>
public static class NotificationSpool
{
public static readonly string Dir =
Path.Combine(Path.GetTempPath(), "claude-notify-spool");
// CLI 侧:写入一条请求,必要时拉起 Host,全程不阻塞
public static void Deliver(NotifyMessage message)
{
try
{
Directory.CreateDirectory(Dir);
// 先写 .tmp 再原子改名为 .json,避免 Host 读到半截文件
var id = Guid.NewGuid().ToString("N");
var tmp = Path.Combine(Dir, id + ".tmp");
var final = Path.Combine(Dir, id + ".json");
File.WriteAllText(tmp, JsonSerializer.Serialize(message, AppJsonContext.Default.NotifyMessage));
File.Move(tmp, final);
}
catch
{
// 写入失败则放弃这条通知,绝不影响调用方
}
EnsureHostRunning();
}
// Host 未运行则拉起(不等待);运行中则什么都不做
private static void EnsureHostRunning()
{
try
{
if (Mutex.TryOpenExisting(IpcConstants.HostMutexName, out var existing))
{
existing.Dispose();
return;
}
}
catch
{
// 打不开就当作未运行,继续尝试拉起
}
try
{
var exe = Environment.ProcessPath;
if (exe is null)
{
return;
}
// 必须用 UseShellExecute=true 让 host 彻底脱离本进程的标准句柄
// 否则常驻 host 会继承并攥住钩子的 stdout 管道,导致 Claude Code
// 等不到管道 EOF 而卡在 "running stop hook"
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "host",
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden,
});
}
catch
{
// 拉起失败则该通知会在下次 Host 启动时由 DrainExisting 补弹
}
}
// Host 侧:消费单个 spool 文件并删除
public static NotifyMessage? ReadAndRemove(string path)
{
try
{
var json = File.ReadAllText(path);
File.Delete(path);
return JsonSerializer.Deserialize(json, AppJsonContext.Default.NotifyMessage);
}
catch
{
return null;
}
}
// Host 启动时把已有的 spool 文件补弹一遍
public static void DrainExisting(Action<NotifyMessage> handler)
{
try
{
if (!Directory.Exists(Dir))
{
return;
}
foreach (var path in Directory.GetFiles(Dir, "*.json"))
{
if (ReadAndRemove(path) is { } msg)
{
handler(msg);
}
}
}
catch
{
// 忽略
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace Notify.Ipc;
/// <summary>
/// 一条投递给 Host 的弹窗请求,经 spool 文件传递
/// </summary>
public sealed class NotifyMessage
{
public string Title { get; set; } = "";
public string Message { get; set; } = "";
// true = 需要输入(青色边框)
public bool InputMode { get; set; }
// true = 常驻,不自动消失
public bool Sticky { get; set; }
// 触发该通知的会话 id
public string? SessionId { get; set; }
// 点击 toast 时要激活的目标窗口句柄,0 表示无
public long TargetHwnd { get; set; }
// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
public string? WtRuntimeId { get; set; }
// 调用方 App 的 exe 路径,用于显示其图标
public string? IconPath { get; set; }
}
+43
View File
@@ -0,0 +1,43 @@
using System;
using System.IO;
namespace Notify.Ipc;
/// <summary>
/// Host 侧:监视 spool 目录,新文件出现即消费并回调
/// </summary>
public sealed class SpoolWatcher
{
private readonly Action<NotifyMessage> _onMessage;
private FileSystemWatcher? _watcher;
public SpoolWatcher(Action<NotifyMessage> onMessage) => _onMessage = onMessage;
public void Start()
{
Directory.CreateDirectory(NotificationSpool.Dir);
// 先补弹启动前堆积的请求
NotificationSpool.DrainExisting(_onMessage);
_watcher = new FileSystemWatcher(NotificationSpool.Dir, "*.json")
{
NotifyFilter = NotifyFilters.FileName,
EnableRaisingEvents = true,
};
_watcher.Created += OnCreated;
_watcher.Renamed += OnRenamed;
}
private void OnCreated(object sender, FileSystemEventArgs e) => Handle(e.FullPath);
private void OnRenamed(object sender, RenamedEventArgs e) => Handle(e.FullPath);
private void Handle(string path)
{
if (NotificationSpool.ReadAndRemove(path) is { } msg)
{
_onMessage(msg);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Notify.Models;
/// <summary>
/// 每会话持久化的状态,由 save 钩子写入、notify 钩子与点击激活读取
/// </summary>
public sealed class StateData
{
// 触发时的前台窗口句柄
public long Hwnd { get; set; }
// 用户当次输入的 prompt,用作"任务完成"通知的正文
public string Prompt { get; set; } = "";
// 若前台是 Windows Terminal,记录当时选中标签的 RuntimeId,用于点击后切回
public string WtRuntimeId { get; set; } = "";
// 调用方 App 的 exe 路径,用于提取并显示其图标
public string CallerExePath { get; set; } = "";
}
+41
View File
@@ -0,0 +1,41 @@
namespace Notify.Models;
/// <summary>
/// 一次弹窗请求(后续由 hook / named pipe 投递)
/// </summary>
public sealed class ToastRequest
{
public required string Title { get; init; }
public required string Message { get; init; }
/// <summary>
/// true = 需要输入(黄色边框),false = 任务完成(橙色边框)
/// </summary>
public bool InputMode { get; init; }
/// <summary>
/// true = 常驻:不自动消失,只能点击 / ✕ 关闭
/// </summary>
public bool Sticky { get; init; }
/// <summary>
/// 点击 toast 主体时要激活的窗口句柄,0 表示不激活
/// </summary>
public long TargetHwnd { get; init; }
/// <summary>
/// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
/// </summary>
public string? WtRuntimeId { get; init; }
/// <summary>
/// 调用方 App 的 exe 路径,用于显示其图标
/// </summary>
public string? IconPath { get; init; }
/// <summary>
/// 覆盖停留秒数;为 null 时用设置里的默认值
/// </summary>
public int? DurationSecondsOverride { get; init; }
}
+84
View File
@@ -0,0 +1,84 @@
namespace Notify.Models;
/// <summary>
/// 水平方向:决定 toast 贴左/居中/贴右
/// </summary>
public enum HEdge
{
Left,
Center,
Right,
}
/// <summary>
/// 垂直方向:决定 toast 贴上/居中/贴下,并决定堆叠方向
/// </summary>
public enum VEdge
{
Top,
Center,
Bottom,
}
/// <summary>
/// 持久化的弹窗设置(纯数据模型,序列化到磁盘)
/// </summary>
public sealed class ToastSettings
{
/// <summary>
/// 自动消失前的停留秒数
/// </summary>
public int DurationSeconds { get; set; } = 4;
/// <summary>
/// 目标窗口已在前台时的停留秒数(你正盯着看,弹一下即可)
/// </summary>
public int FocusedDurationSeconds { get; set; } = 2;
/// <summary>
/// 水平方向
/// </summary>
public HEdge Horizontal { get; set; } = HEdge.Right;
/// <summary>
/// 垂直方向
/// </summary>
public VEdge Vertical { get; set; } = VEdge.Bottom;
/// <summary>
/// 与屏幕边缘的留白(DIP
/// </summary>
public int Margin { get; set; } = 12;
/// <summary>
/// 不透明度 01
/// </summary>
public double Opacity { get; set; } = 0.96;
/// <summary>
/// 最多同时可见的 toast 数量,超出则排队
/// </summary>
public int MaxVisible { get; set; } = 5;
/// <summary>
/// 是否播放提示音
/// </summary>
public bool PlaySound { get; set; } = true;
/// <summary>
/// toast 宽度(DIP
/// </summary>
public double Width { get; set; } = 340;
/// <summary>
/// 淡入/淡出时长(毫秒)
/// </summary>
public int FadeMilliseconds { get; set; } = 300;
/// <summary>
/// 跨所有虚拟桌面显示(未公开 API,失败自动退回单桌面)
/// </summary>
public bool ShowOnAllDesktops { get; set; } = true;
public ToastSettings Clone() => (ToastSettings)MemberwiseClone();
}
+82
View File
@@ -0,0 +1,82 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets\claude.ico</ApplicationIcon>
<RootNamespace>Notify</RootNamespace>
<AssemblyName>notify</AssemblyName>
<Version>1.0.0</Version>
<IsAotCompatible>true</IsAotCompatible>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- NativeAOT 发布配置(仅 publish 生效):单文件原生 exe -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<StripSymbols>true</StripSymbols>
<DebuggerSupport>false</DebuggerSupport>
<OptimizationPreference>Size</OptimizationPreference>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<!-- 这些 UI 库未完全标注 trim/AOT 安全,整体保留避免裁剪导致运行时异常 -->
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<TrimmerRootAssembly Include="Ursa" />
<TrimmerRootAssembly Include="Ursa.Themes.Semi" />
<TrimmerRootAssembly Include="Semi.Avalonia" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Semi.Avalonia" Version="12.0.3" />
<PackageReference Include="Irihi.Ursa" Version="2.0.1" />
<PackageReference Include="Irihi.Ursa.Themes.Semi" Version="2.0.1" />
</ItemGroup>
<!--
AOT 单文件:把 Skia / HarfBuzz / ANGLE 三个原生库静态链接进 exe
CoreUtils.SkiaSharp.Static 含 skia + libHarfBuzzSharp 的 .libCoreUtils.ANGLE.Static 含 ANGLE
两包各自的 .targets 会在 PublishAot 时自动追加 NativeLibrary,这里只补 DirectPInvoke 与系统 lib
版本对应:Avalonia 12 → SkiaSharp 3.119
-->
<ItemGroup>
<PackageReference Include="CoreUtils.SkiaSharp.Static" Version="3.119.0.1" />
<PackageReference Include="CoreUtils.ANGLE.Static" Version="7151.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<DirectPInvoke Include="libSkiaSharp" />
<DirectPInvoke Include="libHarfBuzzSharp" />
<DirectPInvoke Include="av_libglesv2" />
<!-- ANGLE 静态库需要的系统库 -->
<LinkerArg Include="gdi32.lib" />
<LinkerArg Include="user32.lib" />
<LinkerArg Include="d3d9.lib" />
<LinkerArg Include="dxgi.lib" />
<LinkerArg Include="dxguid.lib" />
<LinkerArg Include="synchronization.lib" />
</ItemGroup>
<!-- 静态链接后清理发布目录:动态原生 DLL、链接期 .lib、调试 .pdb 都不需要,只留 notify.exe -->
<Target Name="CleanAotSingleFileOutput" AfterTargets="Publish" Condition="'$(PublishAot)' == 'true'">
<ItemGroup>
<_AotJunk Include="$(PublishDir)*.dll" />
<_AotJunk Include="$(PublishDir)*.lib" />
<_AotJunk Include="$(PublishDir)*.pdb" />
</ItemGroup>
<Delete Files="@(_AotJunk)" />
</Target>
</Project>
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Notify.Cli;
using Notify.Ipc;
namespace Notify;
internal static class Program
{
// 保活期间持有,确保 Host 单例
private static Mutex? _hostMutex;
// Avalonia configuration, don't remove; also used by the visual designer.
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
[STAThread]
public static int Main(string[] args)
{
var mode = args.Length > 0 ? args[0].ToLowerInvariant() : "host";
// 钩子子命令:纯互操作 / IPC,不加载 Avalonia
return mode switch
{
"save" => CliRunner.Save(),
"notify" => CliRunner.Notify(),
"input" => CliRunner.Input(),
"cleanup" => CliRunner.Cleanup(),
_ => RunHost(args),
};
}
// 常驻 Host:加载 Avalonia,无主窗口保活,监听命名管道
private static int RunHost(string[] args)
{
_hostMutex = new Mutex(true, IpcConstants.HostMutexName, out var created);
if (!created)
{
// 已有 Host 在跑,本进程退出
return 0;
}
BuildAvaloniaApp()
// OnExplicitShutdown = 持续保活:没有主窗口也不会退出,只有显式 Shutdown 才结束
.StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown);
return 0;
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
using Notify.Cli;
using Notify.Ipc;
using Notify.Models;
namespace Notify.Serialization;
// System.Text.Json 源生成:为后续 NativeAOT 准备,避免反射序列化被裁剪
[JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true)]
[JsonSerializable(typeof(ToastSettings))]
[JsonSerializable(typeof(StateData))]
[JsonSerializable(typeof(HookInput))]
[JsonSerializable(typeof(NotifyMessage))]
internal partial class AppJsonContext : JsonSerializerContext;
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.IO;
using System.Text.Json;
using Notify.Models;
using Notify.Serialization;
namespace Notify.Services;
/// <summary>
/// 加载/保存弹窗设置,并在变更时通知订阅者
/// </summary>
public sealed class SettingsService
{
private static readonly string Dir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ClaudeCodeNotify");
private static readonly string FilePath = Path.Combine(Dir, "settings.json");
public ToastSettings Current { get; private set; } = new();
/// <summary>
/// 设置被保存后触发
/// </summary>
public event Action<ToastSettings>? Changed;
public void Load()
{
try
{
if (File.Exists(FilePath))
{
var json = File.ReadAllText(FilePath);
var loaded = JsonSerializer.Deserialize(json, AppJsonContext.Default.ToastSettings);
if (loaded is not null)
{
Current = loaded;
}
}
}
catch
{
// 配置损坏则回退到默认值,不影响保活
Current = new ToastSettings();
}
}
public void Save(ToastSettings settings)
{
Current = settings;
try
{
Directory.CreateDirectory(Dir);
var json = JsonSerializer.Serialize(settings, AppJsonContext.Default.ToastSettings);
File.WriteAllText(FilePath, json);
}
catch
{
// 落盘失败不致命,内存里仍生效
}
Changed?.Invoke(Current);
}
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using Notify.Models;
using Notify.Serialization;
namespace Notify.Services;
/// <summary>
/// 每会话状态文件的读写,位于 %TEMP%\claude-notify-{session_id}.json
/// </summary>
public static class StateStore
{
private static string FilePath(string sessionId) =>
Path.Combine(Path.GetTempPath(), $"claude-notify-{Sanitize(sessionId)}.json");
public static void Save(string sessionId, StateData data)
{
try
{
File.WriteAllText(FilePath(sessionId), JsonSerializer.Serialize(data, AppJsonContext.Default.StateData));
}
catch
{
// 落盘失败不致命
}
}
public static StateData? Load(string sessionId)
{
try
{
var path = FilePath(sessionId);
if (File.Exists(path))
{
return JsonSerializer.Deserialize(File.ReadAllText(path), AppJsonContext.Default.StateData);
}
}
catch
{
// 损坏或不可读则当作无状态
}
return null;
}
public static void Delete(string sessionId)
{
try
{
var path = FilePath(sessionId);
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// 忽略
}
}
// 只保留文件名安全字符,避免 session_id 含特殊字符破坏路径
private static string Sanitize(string s) =>
new(s.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_').ToArray());
}
+120
View File
@@ -0,0 +1,120 @@
using System.Collections.Generic;
using Avalonia;
using Avalonia.Platform;
using Notify.Models;
using Notify.ViewModels;
using Notify.Views;
namespace Notify.Services;
/// <summary>
/// 在常驻进程内管理所有 toast 窗口:创建、按角落堆叠、关闭后重新排布
/// 这是 Rust 版"每条通知一进程 + EnumWindows"的替代——进程内一个列表即可
/// </summary>
public sealed class ToastManager
{
private const int Gap = 8;
private readonly SettingsService _settings;
private readonly List<ToastWindow> _active = [];
private readonly Queue<ToastRequest> _pending = new();
public ToastManager(SettingsService settings) => _settings = settings;
public void Show(ToastRequest request)
{
var settings = _settings.Current;
if (_active.Count >= settings.MaxVisible)
{
_pending.Enqueue(request);
return;
}
var vm = new ToastViewModel(request);
var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId, request.IconPath, request.DurationSecondsOverride);
window.Closed += OnToastClosed;
_active.Add(window);
// 先显示(拿到尺寸/屏幕信息),再排布
window.Show();
Arrange();
}
private void OnToastClosed(object? sender, System.EventArgs e)
{
if (sender is ToastWindow w)
{
w.Closed -= OnToastClosed;
_active.Remove(w);
}
Arrange();
if (_pending.Count > 0 && _active.Count < _settings.Current.MaxVisible)
{
Show(_pending.Dequeue());
}
}
/// <summary>
/// 把所有活动 toast 从指定角落沿垂直方向依次堆叠
/// </summary>
private void Arrange()
{
if (_active.Count == 0)
{
return;
}
var anchor = _active[0];
var screen = anchor.Screens.ScreenFromWindow(anchor) ?? anchor.Screens.Primary;
if (screen is null)
{
return;
}
var settings = _settings.Current;
var wa = screen.WorkingArea; // 物理像素
var scale = anchor.RenderScaling;
var margin = (int)(settings.Margin * scale);
var gap = (int)(Gap * scale);
// 垂直靠下时向上堆叠,否则从锚点向下堆叠
var stackUp = settings.Vertical == VEdge.Bottom;
var cursor = settings.Vertical switch
{
VEdge.Top => wa.Y + margin,
VEdge.Bottom => wa.Bottom - margin,
_ => wa.Y + wa.Height / 2,
};
foreach (var toast in _active)
{
var wPx = (int)(toast.Width * scale);
var hPx = (int)(toast.Bounds.Height * scale);
var x = settings.Horizontal switch
{
HEdge.Left => wa.X + margin,
HEdge.Right => wa.Right - margin - wPx,
_ => wa.X + (wa.Width - wPx) / 2,
};
int y;
if (stackUp)
{
cursor -= hPx;
y = cursor;
cursor -= gap;
}
else
{
y = cursor;
cursor += hPx + gap;
}
toast.Position = new PixelPoint(x, y);
}
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Notify.Models;
using Notify.Services;
namespace Notify.ViewModels;
public partial class SettingsViewModel : ObservableObject
{
private readonly SettingsService _settings;
private readonly ToastManager _toasts;
public SettingsViewModel(SettingsService settings, ToastManager toasts)
{
_settings = settings;
_toasts = toasts;
var s = settings.Current;
DurationSeconds = s.DurationSeconds;
FocusedDurationSeconds = s.FocusedDurationSeconds;
Horizontal = s.Horizontal;
Vertical = s.Vertical;
Margin = s.Margin;
Opacity = s.Opacity;
MaxVisible = s.MaxVisible;
PlaySound = s.PlaySound;
Width = s.Width;
FadeMilliseconds = s.FadeMilliseconds;
ShowOnAllDesktops = s.ShowOnAllDesktops;
}
public IReadOnlyList<HEdge> Horizontals { get; } = [HEdge.Left, HEdge.Center, HEdge.Right];
public IReadOnlyList<VEdge> Verticals { get; } = [VEdge.Top, VEdge.Center, VEdge.Bottom];
[ObservableProperty]
public partial int DurationSeconds { get; set; }
[ObservableProperty]
public partial int FocusedDurationSeconds { get; set; }
[ObservableProperty]
public partial HEdge Horizontal { get; set; }
[ObservableProperty]
public partial VEdge Vertical { get; set; }
[ObservableProperty]
public partial int Margin { get; set; }
[ObservableProperty]
public partial double Opacity { get; set; }
[ObservableProperty]
public partial int MaxVisible { get; set; }
[ObservableProperty]
public partial bool PlaySound { get; set; }
[ObservableProperty]
public partial double Width { get; set; }
[ObservableProperty]
public partial int FadeMilliseconds { get; set; }
[ObservableProperty]
public partial bool ShowOnAllDesktops { get; set; }
[ObservableProperty]
public partial string StatusText { get; set; } = string.Empty;
[RelayCommand]
private void Save()
{
_settings.Save(new ToastSettings
{
DurationSeconds = DurationSeconds,
FocusedDurationSeconds = FocusedDurationSeconds,
Horizontal = Horizontal,
Vertical = Vertical,
Margin = Margin,
Opacity = Opacity,
MaxVisible = MaxVisible,
PlaySound = PlaySound,
Width = Width,
FadeMilliseconds = FadeMilliseconds,
ShowOnAllDesktops = ShowOnAllDesktops,
});
StatusText = "已保存";
}
[RelayCommand]
private void TestToast()
{
// 用当前编辑中的值预览(先保存再弹,所见即所得)
Save();
_toasts.Show(new ToastRequest
{
Title = "预览弹窗",
Message = "这是一条测试通知 — 点击可关闭",
InputMode = false,
});
}
}
+23
View File
@@ -0,0 +1,23 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Notify.Models;
namespace Notify.ViewModels;
public partial class ToastViewModel : ObservableObject
{
public ToastViewModel(ToastRequest request)
{
Title = request.Title;
Message = request.Message;
InputMode = request.InputMode;
}
[ObservableProperty]
public partial string Title { get; set; }
[ObservableProperty]
public partial string Message { get; set; }
[ObservableProperty]
public partial bool InputMode { get; set; }
}
+84
View File
@@ -0,0 +1,84 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:u="https://irihi.tech/ursa"
xmlns:vm="clr-namespace:Notify.ViewModels"
xmlns:m="clr-namespace:Notify.Models"
x:Class="Notify.Views.SettingsWindow"
x:DataType="vm:SettingsViewModel"
Width="420"
SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen"
Icon="/Assets/claude.ico"
Title="弹窗设置">
<StackPanel Margin="20" Spacing="16">
<TextBlock Text="弹窗设置"
FontSize="18"
FontWeight="Bold" />
<u:Form LabelPosition="Left" LabelWidth="120">
<u:FormItem Label="停留时长(秒)">
<u:NumericIntUpDown Value="{Binding DurationSeconds}" Minimum="1" Maximum="60" />
</u:FormItem>
<u:FormItem Label="聚焦时停留(秒)">
<u:NumericIntUpDown Value="{Binding FocusedDurationSeconds}" Minimum="1" Maximum="60" />
</u:FormItem>
<u:FormItem Label="水平方向">
<ComboBox ItemsSource="{Binding Horizontals}"
SelectedItem="{Binding Horizontal}"
HorizontalAlignment="Stretch" />
</u:FormItem>
<u:FormItem Label="垂直方向">
<ComboBox ItemsSource="{Binding Verticals}"
SelectedItem="{Binding Vertical}"
HorizontalAlignment="Stretch" />
</u:FormItem>
<u:FormItem Label="边缘留白(DIP">
<u:NumericIntUpDown Value="{Binding Margin}" Minimum="0" Maximum="200" Step="2" />
</u:FormItem>
<u:FormItem Label="不透明度">
<u:NumericDoubleUpDown Value="{Binding Opacity}" Minimum="0.3" Maximum="1.0" Step="0.05" />
</u:FormItem>
<u:FormItem Label="最多同时显示">
<u:NumericIntUpDown Value="{Binding MaxVisible}" Minimum="1" Maximum="10" />
</u:FormItem>
<u:FormItem Label="宽度(DIP">
<u:NumericDoubleUpDown Value="{Binding Width}" Minimum="240" Maximum="600" Step="10" />
</u:FormItem>
<u:FormItem Label="淡入淡出(毫秒)">
<u:NumericIntUpDown Value="{Binding FadeMilliseconds}" Minimum="0" Maximum="2000" Step="50" />
</u:FormItem>
<u:FormItem Label="播放提示音">
<ToggleSwitch IsChecked="{Binding PlaySound}" />
</u:FormItem>
<u:FormItem Label="跨所有桌面显示">
<ToggleSwitch IsChecked="{Binding ShowOnAllDesktops}" />
</u:FormItem>
</u:Form>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock Text="{Binding StatusText}"
VerticalAlignment="Center"
Foreground="#FF4CAF50" />
<Button Content="测试弹窗" Command="{Binding TestToastCommand}" />
<Button Content="保存"
Classes="Primary"
Command="{Binding SaveCommand}" />
</StackPanel>
</StackPanel>
</Window>
+8
View File
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace Notify.Views;
public partial class SettingsWindow : Window
{
public SettingsWindow() => InitializeComponent();
}
+59
View File
@@ -0,0 +1,59 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Notify.ViewModels"
x:Class="Notify.Views.ToastWindow"
x:DataType="vm:ToastViewModel"
Width="340"
Height="92"
CanResize="False"
ShowInTaskbar="False"
ShowActivated="False"
Topmost="True"
WindowDecorations="None"
Background="Transparent"
TransparencyLevelHint="Transparent">
<Border x:Name="Root"
Background="#FF2B2B2B"
CornerRadius="10"
BorderThickness="2"
BorderBrush="#FF4B64B2"
BoxShadow="0 6 24 0 #80000000"
Padding="14"
PointerEntered="OnPointerEntered"
PointerExited="OnPointerExited"
PointerPressed="OnBodyPressed">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<Image Grid.Column="0"
x:Name="IconImage"
Width="44" Height="44"
VerticalAlignment="Center"
Source="/Assets/claude.ico" />
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{Binding Title}"
FontWeight="Bold"
FontSize="14"
Foreground="#FFFFFFFF"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Message}"
FontSize="12"
Foreground="#FFCCCCCC"
TextWrapping="Wrap"
MaxLines="2"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<Button Grid.Column="2"
Content="✕"
VerticalAlignment="Top"
Padding="6,2"
FontSize="12"
Foreground="#FF888888"
Background="Transparent"
BorderThickness="0"
Click="OnCloseClick" />
</Grid>
</Border>
</Window>
+197
View File
@@ -0,0 +1,197 @@
using System;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using Notify.Models;
using Notify.ViewModels;
namespace Notify.Views;
public partial class ToastWindow : Window
{
private static readonly Color BorderNormal = Color.Parse("#FF4B64B2");
private static readonly Color BorderInput = Color.Parse("#FF00CFCF");
private readonly ToastSettings _settings;
private readonly DispatcherTimer _dismissTimer;
private readonly bool _sticky;
private readonly long _targetHwnd;
private readonly string? _wtRuntimeId;
private Avalonia.Media.Imaging.Bitmap? _appIcon;
private bool _closing;
// 设计器需要的无参构造
public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null, null, null)
{
}
public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId, string? iconPath, int? durationOverride)
{
_settings = settings;
_targetHwnd = targetHwnd;
_wtRuntimeId = wtRuntimeId;
var duration = durationOverride ?? settings.DurationSeconds;
// 常驻:请求显式 Sticky,或停留时长 <= 0
_sticky = sticky || duration <= 0;
InitializeComponent();
DataContext = vm;
Width = settings.Width;
Opacity = 0;
// Opacity 过渡用于淡入/淡出
Transitions =
[
new DoubleTransition
{
Property = OpacityProperty,
Duration = TimeSpan.FromMilliseconds(settings.FadeMilliseconds),
Easing = new Avalonia.Animation.Easings.CubicEaseOut(),
},
];
Root.BorderBrush = new SolidColorBrush(vm.InputMode ? BorderInput : BorderNormal);
// 调用方 App 图标,取不到则保留默认 Claude 图标
if (!string.IsNullOrEmpty(iconPath))
{
_appIcon = Notify.Interop.AppIcon.Extract(iconPath);
if (_appIcon is not null)
{
IconImage.Source = _appIcon;
}
}
_dismissTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(Math.Max(1, duration)),
};
_dismissTimer.Tick += (_, _) => BeginClose();
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
_appIcon?.Dispose();
_appIcon = null;
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
Opacity = _settings.Opacity; // 触发淡入
if (TryGetPlatformHandle()?.Handle is { } hwnd)
{
// 工具窗口:从任务栏与 Alt+Tab 中隐藏
Notify.Interop.Win32.MakeToolWindow(hwnd);
// 跨虚拟桌面:把窗口钉到所有桌面(失败自动忽略)
if (_settings.ShowOnAllDesktops)
{
TryPinWithRetry(hwnd);
}
}
if (!_sticky)
{
_dismissTimer.Start();
}
}
/// <summary>
/// 窗口刚打开时 shell 可能还没给它登记 ApplicationViewGetViewForHwnd 报
/// TYPE_E_ELEMENTNOTFOUND),故短间隔重试若干次直到成功
/// </summary>
private void TryPinWithRetry(IntPtr hwnd)
{
if (Notify.Interop.VirtualDesktopPinner.TryPin(hwnd))
{
return;
}
var attempts = 0;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(60) };
timer.Tick += (_, _) =>
{
attempts++;
if (_closing || Notify.Interop.VirtualDesktopPinner.TryPin(hwnd) || attempts >= 15)
{
timer.Stop();
}
};
timer.Start();
}
private void OnPointerEntered(object? sender, PointerEventArgs e)
{
// 悬停时暂停自动消失
_dismissTimer.Stop();
if (!_closing)
{
Opacity = _settings.Opacity;
}
}
private void OnPointerExited(object? sender, PointerEventArgs e)
{
if (!_closing && !_sticky)
{
_dismissTimer.Start();
}
}
private void OnBodyPressed(object? sender, PointerPressedEventArgs e)
{
// 左键点击主体:激活原窗口后关闭
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
if (_targetHwnd != 0)
{
var target = new IntPtr(_targetHwnd);
Notify.Interop.WindowActivator.Activate(target);
// 目标是 Windows Terminal 则切回原标签
if (!string.IsNullOrEmpty(_wtRuntimeId))
{
Notify.Interop.WinTerminalTabs.SelectTab(target, _wtRuntimeId);
}
}
BeginClose();
}
}
private void OnCloseClick(object? sender, RoutedEventArgs e) => BeginClose();
/// <summary>
/// 淡出后再真正关闭
/// </summary>
private void BeginClose()
{
if (_closing)
{
return;
}
_closing = true;
_dismissTimer.Stop();
Opacity = 0;
var closeTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(_settings.FadeMilliseconds),
};
closeTimer.Tick += (_, _) =>
{
closeTimer.Stop();
Close();
};
closeTimer.Start();
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Notify.app" />
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+161
View File
@@ -0,0 +1,161 @@
# Claude Code Notify
> 为 Claude Code 提供原生 Windows 通知:任务完成或需要你输入时弹出 toast,**点击即可跳回原终端 / 编辑器窗口**(并能切回正确的 Windows Terminal 标签页)。
原版 Rust 项目的 **C# / .NET 10 + Avalonia 12** 重写版,采用「CLI 子命令 + 常驻 Host」进程模型,由 Claude Code 的 hook 驱动。仅支持 Windows 10 / 11 (x64)。
## 功能
- 任务完成 / 需要输入时弹出原生 toast,可堆叠、自由定位(水平 × 垂直 + 留白)、跨虚拟桌面显示
- 点击 toast 跳回发起请求的窗口,并能切回原 Windows Terminal 标签页
- 自动识别并显示调用方 App 图标(VSCode / Cursor / JetBrains / 终端…)
- 输入类通知常驻、完成类自动消失(正盯着目标窗口时停留更短)
- 非阻塞投递,钩子毫秒级返回,不拖慢 Claude Code
- NativeAOT 单文件、无运行时依赖
## 架构
```mermaid
flowchart LR
CC[Claude Code] -->|UserPromptSubmit| SAVE["notify save"]
CC -->|Stop| NOTIFY["notify notify"]
CC -->|Notification / PreToolUse| INPUT["notify input"]
CC -->|SessionEnd| CLEAN["notify cleanup"]
SAVE --> ST[(状态文件)]
CLEAN -.删除.-> ST
NOTIFY --> SP[(spool 队列)]
INPUT --> SP
SP --> HOST
ST -.读取.-> NOTIFY
ST -.读取.-> INPUT
subgraph HOST["notify host (Avalonia 常驻)"]
W[FileSystemWatcher] --> T1[Toast]
W --> T2[Toast]
end
T1 -->|点击| ACT[激活窗口 + 切 WT 标签]
```
- **CLI 子命令**`save` / `notify` / `input` / `cleanup`):纯 Win32 互操作 + 落盘,不加载 Avalonia,做完即退,毫秒级返回。
- **Host**Avalonia 单例常驻,仅托盘存在,监视 spool 目录弹通知。
详见 [docs/architecture.md](docs/architecture.md)。
## 时序
### 整体生命周期
```mermaid
sequenceDiagram
autonumber
actor User as 用户
participant CC as Claude Code
participant Cli as notify (CLI)
participant State as 状态文件
participant Spool as spool 队列
participant Host as notify host
participant Win as 目标窗口
User->>CC: 发送消息
CC->>Cli: notify save (stdin: session_id + prompt)
Cli->>Cli: 抓前台窗口 / 进程树取图标 / WT 标签
Cli->>State: 写入状态
Cli-->>CC: 立即退出
Note over CC: Claude 处理中…
alt 任务完成
CC->>Cli: notify notify
else 需要输入 / 提问 / 出 Plan
CC->>Cli: notify input
end
Cli->>State: 读取状态
Cli->>Spool: 原子写入 NotifyMessage
Cli->>Host: 不在则拉起(不等待)
Cli-->>CC: 立即退出(~100ms
Host->>Spool: FileSystemWatcher 消费
Host-->>User: 弹出 toast
User->>Host: 左键点击 toast
Host->>Win: 抢前台激活 (+ 切回 WT 标签)
Win-->>User: 回到原窗口
User->>CC: 结束会话
CC->>State: notify cleanup 删除状态
```
### 非阻塞投递
CLI 写完 spool 即走、不等 Host,因此钩子毫秒级返回。
```mermaid
sequenceDiagram
autonumber
participant Cli as notify notify/input
participant Spool as spool 目录
participant Mtx as 单例互斥量
participant Host as notify host
Cli->>Spool: 写 tmp → 原子改名 json
Cli->>Mtx: TryOpenExisting
alt Host 已在跑
Mtx-->>Cli: 存在 → 不拉起
else Host 未运行
Cli->>Host: Process.Start("host", UseShellExecute=true)
end
Cli-->>Cli: 立即返回(~100ms
Host->>Spool: 启动 DrainExisting + Watcher 增量
Host->>Host: 读取并删除文件 → UI 线程弹 toast
```
### 点击 toast → 激活原窗口
```mermaid
sequenceDiagram
autonumber
actor User as 用户
participant Toast as ToastWindow
participant Act as WindowActivator
participant WT as WinTerminalTabs
participant Win as 目标窗口
User->>Toast: 左键点击主体
Toast->>Act: Activate(targetHwnd)
Act->>Win: ALT 模拟 + AttachThreadInput + SetForegroundWindow 组合技
Win-->>User: 窗口回到前台
opt 目标是 Windows Terminal 且有 RuntimeId
Toast->>WT: SelectTab(hwnd, runtimeId)
WT->>Win: UIAutomation 找到标签 → Select()
end
Toast->>Toast: 淡出关闭
```
## 安装
```bash
claude plugin marketplace add https://git.pchuan.top/cc-tools/notify
claude plugin install claude-code-notify@claude-code-notify
```
重启 Claude Code 后即生效。首次触发钩子时,`scripts/notify.cmd` 会自动从 Release 下载单文件 `notify.exe`,之后常驻。
- 托盘**左键单击**打开设置,**右键**退出。
- 从源码构建见 [docs/build-and-install.md](docs/build-and-install.md)。
## 文档
| 文档 | 内容 |
|------|------|
| [architecture.md](docs/architecture.md) | 进程模型、组件、目录结构 |
| [hooks-and-cli.md](docs/hooks-and-cli.md) | hook 事件、子命令、stdin、状态文件 |
| [interop.md](docs/interop.md) | 原生互操作与 AOT 说明 |
| [build-and-install.md](docs/build-and-install.md) | 构建、安装、排错 |
## 许可
[MIT](LICENSE)
+47
View File
@@ -0,0 +1,47 @@
{
"IndentSize": 4,
"IndentWithTabs": null,
"AttributesTolerance": 5,
"KeepFirstAttributeOnSameLine": false,
"MaxAttributeCharactersPerLine": 80,
"MaxAttributesPerLine": 0,
"NewlineExemptionElements": "RadialGradientBrush, GradientStop, LinearGradientBrush, ScaleTransform, SkewTransform, RotateTransform, TranslateTransform, Trigger, Condition, Setter",
"SeparateByGroups": true,
"AttributeIndentation": 0,
"AttributeIndentationStyle": "Spaces",
"RemoveDesignTimeReferences": false,
"EnableAttributeReordering": true,
"AttributeOrderingRuleGroups": [
"x:Class",
"xmlns, xmlns:x",
"xmlns:*",
"x:Key, Key, x:Name, Name, x:Uid, Uid, Title",
"Grid.Row, Grid.RowSpan, Grid.Column, Grid.ColumnSpan, Canvas.Left, Canvas.Top, Canvas.Right, Canvas.Bottom",
"Width, Height, MinWidth, MinHeight, MaxWidth, MaxHeight",
"Classes, Theme, Styles",
"Margin, Padding, HorizontalAlignment, VerticalAlignment, HorizontalContentAlignment, VerticalContentAlignment, Panel.ZIndex",
"*:*, *",
"PageSource, PageIndex, Offset, Color, TargetName, Property, Value, StartPoint, EndPoint",
"mc:Ignorable, d:IsDataSource, d:LayoutOverrides, d:IsStaticText",
"Storyboard.*, From, To, Duration"
],
"FirstLineAttributes": "",
"OrderAttributesByName": true,
"IgnoreDesignTimeReferencePrefix": false,
"PutEndingBracketOnNewLine": false,
"RemoveEndingTagOfEmptyElement": true,
"SpaceBeforeClosingSlash": false,
"RootElementLineBreakRule": "Default",
"ReorderVSM": "Last",
"ReorderGridChildren": false,
"ReorderCanvasChildren": false,
"ReorderSetters": "None",
"FormatMarkupExtension": false,
"NoNewLineMarkupExtensions": "x:Bind, Binding",
"ThicknessSeparator": "Comma",
"ThicknessAttributes": "Margin, Padding, BorderThickness, ThumbnailClipMargin",
"FormatOnSave": true,
"SaveAndCloseOnFormat": true,
"CommentPadding": 2,
"SuppressProcessing": false
}
+73
View File
@@ -0,0 +1,73 @@
# 架构
## 进程模型:CLI 子命令 + 常驻 Host
整个程序是**单个 exe `notify.exe`**,靠命令行第一个参数分流成两类角色:
| 角色 | 触发方式 | 是否加载 Avalonia | 生命周期 |
|------|----------|------------------|----------|
| **CLI 子命令** | `notify save\|notify\|input\|cleanup` | 否(纯互操作 / 落盘) | 即起即退(~100ms |
| **Host** | `notify``notify host` | 是 | 常驻(单例,无主窗口) |
这样设计的原因:hook 在你每次发消息时都会被拉起,必须**极快返回、绝不阻塞 Claude Code**;而真正画 UI 的 Avalonia 较重,放在一个**只初始化一次**的常驻进程里。
```mermaid
flowchart TD
M["Main(args)"] --> SW{"args[0]"}
SW -->|save| CSAVE[CliRunner.Save]
SW -->|notify| CNOTIFY[CliRunner.Notify]
SW -->|input| CINPUT[CliRunner.Input]
SW -->|cleanup| CCLEAN[CliRunner.Cleanup]
SW -->|其它 / host| RUN[RunHost:单例互斥量 + Avalonia]
CSAVE -. 不加载 Avalonia .-> X1[退出]
CNOTIFY -. 不加载 Avalonia .-> X2[退出]
RUN --> APP[App:托盘 + SpoolWatcher + ToastManager]
```
- **单例**`RunHost` 用命名互斥量 `ClaudeCodeNotifyHost` 保证只有一个 Host;第二个实例直接退出。
- **保活**`ShutdownMode.OnExplicitShutdown`,没有主窗口也不退,只有托盘"退出"才结束。
## 两条数据通道
1. **状态文件**(每会话)`%TEMP%\claude-notify-{session_id}.json`
- `save` 写入:前台窗口句柄、prompt、WT 标签 RuntimeId、调用方 exe 路径。
- `notify`/`input` 读取,拼成通知;`cleanup` 删除。
2. **spool 队列** `%TEMP%\claude-notify-spool\*.json`
- `notify`/`input` 把一条 `NotifyMessage` 原子落盘,Host 用 `FileSystemWatcher` 消费。
- 取代命名管道,**让 CLI 写完即走、不等 Host**(见 README 的「非阻塞投递」时序)。
## 组件 / 目录
```
Notify/
├── Program.cs 入口:子命令分流 + Host 单例
├── App.axaml(.cs) Avalonia 应用:托盘、SpoolWatcher、收到消息弹 toast
├── Cli/
│ ├── HookInput.cs stdin JSON 的 DTO(源生成反序列化)
│ └── CliRunner.cs save/notify/input/cleanup 实现 + 消息清洗
├── Ipc/
│ ├── NotifyMessage.cs 投递给 Host 的弹窗请求 DTO
│ ├── NotificationSpool.cs 落盘投递 + 拉起 Host(客户端)/ 消费(Host)
│ ├── SpoolWatcher.cs Host 侧目录监视
│ └── IpcConstants.cs 互斥量名等
├── Models/
│ ├── ToastSettings.cs 持久化设置 + 枚举(HEdge/VEdge
│ ├── ToastRequest.cs Host 内部的一次弹窗请求
│ └── StateData.cs 每会话状态
├── Services/
│ ├── SettingsService.cs 设置读写
│ ├── StateStore.cs 状态文件读写
│ └── ToastManager.cs toast 创建 / 堆叠定位 / 排队
├── ViewModels/ ToastViewModel、SettingsViewModelpartial 源生成属性)
├── Views/ ToastWindow、SettingsWindow
├── Interop/ 原生互操作(见 interop.md
└── Serialization/
└── AppJsonContext.cs System.Text.Json 源生成上下文
```
## 线程模型
- CLI 子命令在单线程跑完即退。
- Host 里:`FileSystemWatcher` 回调在线程池线程触发 → `App.OnNotify` 里先做一次"是否前台"判断和提示音,再 `Dispatcher.UIThread.Post` 切回 UI 线程创建 toast。
- 所有 Avalonia 对象操作都在 UI 线程;GDI / COM 互操作可在任意线程,但本项目主要在 UI 线程调用。
+59
View File
@@ -0,0 +1,59 @@
# 构建与安装
## 安装(用户)
```bash
claude plugin marketplace add https://git.pchuan.top/cc-tools/notify
claude plugin install claude-code-notify@claude-code-notify
```
重启 Claude Code 后生效。插件的 `hooks/hooks.json` 指向 `${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd`,首次触发钩子时该脚本会从 Release 下载单文件 `notify.exe``bin/`,之后常驻。
引导脚本(`scripts/notify.cmd``scripts/notify.sh`)顶部的 `DOWNLOAD_URL` 决定从哪拉取 exe;下载用临时文件 + 原子改名 + mkdir 锁,并发触发不会重复下载。
## 从源码构建(开发)
框架依赖型,依赖已安装的 .NET 10 运行时:
```bash
cd Notify
dotnet build -c Release
# 产物:Notify/bin/Release/net10.0-windows/notify.exe + 同目录依赖 DLL
```
exe 必须和这些 DLL 在一起(.NET 从 exe 所在目录加载依赖)。
## 发布单文件(维护者)
NativeAOT 静态链接 Skia / HarfBuzz / ANGLE,产出**单个无依赖 exe**。原生链接需要 MSVC 工具链。
```bash
# 从 "Developer Command Prompt for VS" 运行,或用脚本(自动用 vswhere 配 vcvars
scripts\build.bat
# 产物:bin\notify.exe(单文件,~40MB
```
然后把它作为 Release 资产发布,并确保引导脚本的 `DOWNLOAD_URL` 指向它:
```bash
gh release create v0.1.0 bin/notify.exe
```
> AOT 配置在 `Notify/Notify.csproj``PublishAot` 条件块 + `CoreUtils.*.Static` 静态库包 + 发布后清理)。源生成 COM / UIAutomation 与静态渲染需真机运行验证,详见 [interop.md](interop.md)。
## 不接 Claude 的手动烟雾测试
```bash
notify host & # 起 Host(托盘出现)
echo {"session_id":"x","prompt":"hi"} | notify save
echo {"session_id":"x"} | notify notify # 弹窗
```
## 排错
| 现象 | 处理 |
|------|------|
| 首次触发慢 1~2 秒 | 首次会下载 exe + Host 冷启动(一次性),之后常驻、瞬时 |
| 没弹窗 | 先用手动烟雾测试确认程序本身正常;看托盘有没有图标 |
| 弹双份 | 同时装了原版 Rust 插件,删除其一 |
| 停止 Host | 托盘右键 → 退出,或 `taskkill /F /IM notify.exe` |
+60
View File
@@ -0,0 +1,60 @@
# Hook 与 CLI
## Hook → 子命令映射
| Claude Code 事件 | 子命令 | 作用 |
|------------------|--------|------|
| `UserPromptSubmit` | `notify save` | 记录前台窗口、prompt、WT 标签、调用方图标路径 |
| `Stop` | `notify notify` | 弹"任务完成"通知(自动消失,聚焦时更短) |
| `Notification` | `notify input` | 弹"需要输入"通知(常驻),按类型分标题 |
| `PreToolUse``AskUserQuestion`/`ExitPlanMode` | `notify input` | 提问 / 出 Plan 时弹常驻通知 |
| `SessionEnd` | `notify cleanup` | 删除该会话状态文件 |
`hooks/hooks.json`(插件形式)里命令为 `${CLAUDE_PLUGIN_ROOT}/bin/notify.exe <子命令>`;直连 `settings.json` 时可写 `notify <子命令>` 或绝对路径。
## stdin JSON
Claude Code 通过 **stdin** 把事件数据以 JSON 传入。`HookInput` 关心这几个字段:
| 字段 | 用途 |
|------|------|
| `session_id` | 状态文件隔离;为空则忽略本次 |
| `prompt` | UserPromptSubmit 的用户输入,用作"完成"通知正文 |
| `notification_type` | Notification 类型:`permission_prompt` / `idle_prompt` / `elicitation_dialog` / … |
| `message` | Notification / 提问的文本 |
| `tool_name` | PreToolUse 工具名:`AskUserQuestion` / `ExitPlanMode` |
> **注意**stdin 用 `OpenStandardInput()` 读**原始字节**再按 **UTF-8** 解码。不能用 `Console.In`——WinExe 下它不可靠,且会用控制台代码页(中文系统是 GBK)把中文解成乱码。
## 标题分流(input
| 条件 | 标题 |
|------|------|
| `tool_name == AskUserQuestion` | Claude is Asking |
| `tool_name == ExitPlanMode` | Plan Ready for Approval |
| `notification_type == permission_prompt` | Permission Required |
| `notification_type == idle_prompt` | Claude is Waiting |
| `notification_type == elicitation_dialog` | MCP Asks |
| 其它 | Input Required |
被**过滤**(不弹)的类型:`auth_success` / `elicitation_complete` / `elicitation_response`
## 状态文件
路径:`%TEMP%\claude-notify-{session_id}.json``session_id` 做了文件名安全过滤)。
```jsonc
{
"Hwnd": 329712, // 触发时前台窗口句柄
"Prompt": "重构通知模块", // 完成通知正文
"WtRuntimeId": "42.288...",// WT 当前标签 RuntimeId(非 WT 为空)
"CallerExePath": "...\\WindowsTerminal.exe" // 调用方 App,用于取图标
}
```
- `notify`/`input` 读它来填 `NotifyMessage`(含点击要激活的 `TargetHwnd`、要切的标签、要显示的图标)。
- `cleanup` 删它。若 `SessionEnd` 没触发(崩溃等),文件会残留在 `%TEMP%`,无害。
## 消息清洗
`notify`/`input` 投递前会把正文里的换行 / 制表 / 多余空格折叠成单行,避免撑乱 toast 布局;超长部分由 toast 的两行省略号截断。
+39
View File
@@ -0,0 +1,39 @@
# 原生互操作与 AOT
所有 Win32 / COM 互操作都为 **NativeAOT** 准备:用 `LibraryImport`(源生成 P/Invoke)和 `[GeneratedComInterface]`(源生成 COM),**不用** `System.Drawing`、经典 `[ComImport]`、反射式封送(它们在 AOT 下不可用)。
| 文件 | 职责 | 关键点 |
|------|------|--------|
| `Win32.cs` | 基础 P/Invoke | `GetForegroundWindow``GetClassName`、工具窗口样式等 |
| `WindowActivator.cs` | 抢前台激活 | ALT 模拟 + `AttachThreadInput` + 多 API 组合,绕过防焦点抢占 |
| `WinTerminalTabs.cs` | WT 切标签 | 源生成 COM 调 UIAutomation,按 RuntimeId 定位标签 |
| `VirtualDesktopPinner.cs` | 跨虚拟桌面 | 未公开 COM `IVirtualDesktopPinnedApps.PinView` |
| `ProcessTree.cs` | 进程树上溯 | Toolhelp 快照,跳过 shell/运行时找调用方 App |
| `AppIcon.cs` | 取图标 | `ExtractIconEx` + GDI 读 BGRA 像素 → Avalonia 位图 |
| `Sound.cs` | 提示音 | winmm `PlaySound` 从内存异步播放 |
## 窗口激活(WindowActivator
Windows 限制后台进程抢焦点。组合技:还原最小化 → 模拟一次 ALT 抬起 → `AttachThreadInput` 把当前线程与前台/目标线程输入队列挂接 → `AllowSetForegroundWindow` + `SetWindowPos`/`BringWindowToTop`/`SwitchToThisWindow`/`SetForegroundWindow` 多管齐下 → 解除挂接。
## Windows Terminal 切标签(WinTerminalTabs
- `save` 时:检测前台窗口类是否 `CASCADIA_HOSTING_WINDOW_CLASS`;是则用 UIAutomation 找当前选中的 `TabItem`,取其 **RuntimeId**(一串 intSAFEARRAY)存入状态。
- 点击时:激活 WT 窗口后,枚举标签找到 RuntimeId 匹配的,调 `SelectionItemPattern.Select()`
- **AOT 要点**:接口用 `[GeneratedComInterface]`;未用到的 vtable 槽用占位方法按 SDK 头文件顺序补齐(顺序 / GUID 均取自 `UIAutomationClient.h`);`IApplicationView` 以裸 `IntPtr` 传递。
## 跨虚拟桌面(VirtualDesktopPinner
-`CoCreateInstance``CLSCTX_LOCAL_SERVER`)拿 ImmersiveShell → `IApplicationViewCollection.GetViewForHwnd``IVirtualDesktopPinnedApps.PinView`
- GUID 取自 Win11 24H2;整段 try/catch,失败自动退回"仅当前桌面"。
- 窗口刚打开时 view 可能尚未就绪,短间隔重试直到成功。
## 取图标(AppIcon
`ExtractIconEx` 拿 HICON → `GetIconInfo` 取彩色位图 → `GetDIBits` 以 32bpp 自上而下读出 BGRA → 构造 `Avalonia.Media.Imaging.Bitmap`。老图标无 alpha(全 0)时补成不透明,避免整块透明。取不到则回退默认 Claude 图标。
## AOT
- `csproj``IsAotCompatible``PublishAot` 条件块 + `TrimmerRootAssembly`Ursa / Semi 整体保留)+ `CoreUtils.*.Static` 静态链接 Skia / HarfBuzz / ANGLE。
- 原生链接需要 MSVC 工具链(从 "Developer Command Prompt for VS" 跑,或用配好 vcvars 的脚本)。
- 源生成 COM / UIAutomation 与静态渲染需在真机运行验证。
+64
View File
@@ -0,0 +1,64 @@
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd save",
"timeout": 30
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd input",
"timeout": 30
}
]
}
],
"PreToolUse": [
{
"matcher": "AskUserQuestion|ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd input",
"timeout": 30
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd notify",
"timeout": 30
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd cleanup",
"timeout": 10
}
]
}
]
}
}
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="Notify/Notify.csproj" />
</Solution>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<!--<add key="nuget-cdn" value="https://nuget.cdn.azure.cn/v3/index.json" allowInsecureConnections="True" />-->
<add key="nuget-src" value="https://api.nuget.org/v3/index.json" allowInsecureConnections="True" />
</packageSources>
<disabledPackageSources>
<clear />
</disabledPackageSources>
</configuration>
+26
View File
@@ -0,0 +1,26 @@
@echo off
setlocal
rem 切到仓库根目录(scripts 的上一级)
cd /d "%~dp0\.."
rem NativeAOT 的原生链接需要 MSVC 工具链,先用 vswhere 找到 VS 并配置环境
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
if exist "%VSWHERE%" (
for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSPATH=%%i"
)
if defined VSPATH if exist "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" (
echo === 配置 MSVC 环境: %VSPATH% ===
call "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" >nul
)
echo === NativeAOT publish (win-x64) -^> bin\notify.exe ===
dotnet publish Notify -c Release -r win-x64 -p:PublishAot=true -o bin
if errorlevel 1 (
echo.
echo *** 发布失败。若提示找不到 link.exe,请从 "Developer Command Prompt for VS" 运行本脚本 ***
exit /b 1
)
echo.
echo === 完成: %CD%\bin\notify.exe ===
endlocal
+45
View File
@@ -0,0 +1,45 @@
@echo off
rem ============================================================
rem Download URL (notify.exe is fetched from here on first run) -- edit as needed
set "DOWNLOAD_URL=https://git.pchuan.top/cc-tools/notify/releases/download/v1.0.0/notify.exe"
rem ============================================================
setlocal
set "BIN=%~dp0..\bin"
set "EXE=%BIN%\notify.exe"
set "LOCK=%BIN%\notify.download.lock"
rem only bootstrap on first run; the common path runs the exe directly (keeps piped stdin intact)
if not exist "%EXE%" call :bootstrap
if exist "%EXE%" "%EXE%" %*
endlocal
exit /b
:bootstrap
if not exist "%BIN%" mkdir "%BIN%" 2>nul
set "TMP=%BIN%\notify.exe.%RANDOM%.tmp"
rem mkdir is atomic; success = this process downloads, failure = someone else is downloading
mkdir "%LOCK%" 2>nul
if errorlevel 1 goto :waitdl
rem double-check in case it just finished
if exist "%EXE%" ( rmdir "%LOCK%" 2>nul & exit /b )
rem download to temp then atomic rename, so no half-written exe is ever seen
curl -fsSL "%DOWNLOAD_URL%" -o "%TMP%"
if errorlevel 1 ( del "%TMP%" 2>nul & rmdir "%LOCK%" 2>nul & exit /b )
move /y "%TMP%" "%EXE%" >nul
rmdir "%LOCK%" 2>nul
exit /b
:waitdl
rem did not get the lock; wait for the exe to appear (up to ~60s)
set /a _w=0
:waitloop
if exist "%EXE%" exit /b
if %_w% geq 120 (
rem timed out; a killed download may have left a stale lock, clear it for next time
rmdir "%LOCK%" 2>nul
exit /b
)
ping -n 2 127.0.0.1 >nul
set /a _w+=1
goto :waitloop
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# ============================================================
# 下载地址(首次运行从这里拉取 notify.exe)—— 按需修改
DOWNLOAD_URL="https://git.pchuan.top/cc-tools/notify/releases/download/v1.0.0/notify.exe"
# ============================================================
DIR="$(cd "$(dirname "$0")" && pwd)"
BIN="$DIR/../bin"
EXE="$BIN/notify.exe"
LOCK="$BIN/notify.download.lock"
if [ ! -f "$EXE" ]; then
mkdir -p "$BIN" 2>/dev/null
# mkdir 是原子操作,用作锁:成功=本进程负责下载,失败=已有进程在下
if mkdir "$LOCK" 2>/dev/null; then
# 双重检查,避免刚好别人下完
if [ ! -f "$EXE" ]; then
TMP="$BIN/notify.exe.$$.tmp"
if curl -fsSL "$DOWNLOAD_URL" -o "$TMP"; then
mv -f "$TMP" "$EXE" # 原子改名,避免半截 exe
chmod +x "$EXE" 2>/dev/null
else
rm -f "$TMP"
fi
fi
rmdir "$LOCK" 2>/dev/null
else
# 没抢到锁:等 exe 出现(最多约 60 秒)
i=0
while [ ! -f "$EXE" ] && [ "$i" -lt 120 ]; do
sleep 0.5
i=$((i + 1))
done
# 超时仍没下好:可能上次下载被杀留下陈旧锁,清掉让下次重下
[ ! -f "$EXE" ] && rmdir "$LOCK" 2>/dev/null
fi
fi
# 转发全部参数与 stdin 给真正的 exe
[ -f "$EXE" ] && exec "$EXE" "$@"