feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# 批处理脚本必须用 CRLF,否则 cmd 解析会出错
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
# shell 脚本必须用 LF
|
||||
*.sh text eol=lf
|
||||
+477
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project >
|
||||
<ItemGroup>
|
||||
<!--
|
||||
todo: 编译与发布相关配置
|
||||
-->
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 说明:接口用源生成 COM(GeneratedComInterface),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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,点击通知激活窗口后再据此切回该标签
|
||||
/// 全程走源生成 COM(GeneratedComInterface),保证 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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Notify.Ipc;
|
||||
|
||||
internal static class IpcConstants
|
||||
{
|
||||
// 保证 Host 单例的互斥量名(Local 级,按用户会话隔离)
|
||||
public const string HostMutexName = "ClaudeCodeNotifyHost";
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
/// 不透明度 0–1
|
||||
/// </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();
|
||||
}
|
||||
@@ -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 的 .lib,CoreUtils.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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Notify.Views;
|
||||
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
public SettingsWindow() => InitializeComponent();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 可能还没给它登记 ApplicationView(GetViewForHwnd 报
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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、SettingsViewModel(partial 源生成属性)
|
||||
├── 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 线程调用。
|
||||
@@ -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` |
|
||||
@@ -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 的两行省略号截断。
|
||||
@@ -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**(一串 int,SAFEARRAY)存入状态。
|
||||
- 点击时:激活 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 与静态渲染需在真机运行验证。
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="Notify/Notify.csproj" />
|
||||
</Solution>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
Reference in New Issue
Block a user