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

为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows
Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
2026-06-22 18:05:15 +08:00
commit 5ce2c8a982
53 changed files with 3889 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:u="https://irihi.tech/ursa"
xmlns:vm="clr-namespace:Notify.ViewModels"
xmlns:m="clr-namespace:Notify.Models"
x:Class="Notify.Views.SettingsWindow"
x:DataType="vm:SettingsViewModel"
Width="420"
SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen"
Icon="/Assets/claude.ico"
Title="弹窗设置">
<StackPanel Margin="20" Spacing="16">
<TextBlock Text="弹窗设置"
FontSize="18"
FontWeight="Bold" />
<u:Form LabelPosition="Left" LabelWidth="120">
<u:FormItem Label="停留时长(秒)">
<u:NumericIntUpDown Value="{Binding DurationSeconds}" Minimum="1" Maximum="60" />
</u:FormItem>
<u:FormItem Label="聚焦时停留(秒)">
<u:NumericIntUpDown Value="{Binding FocusedDurationSeconds}" Minimum="1" Maximum="60" />
</u:FormItem>
<u:FormItem Label="水平方向">
<ComboBox ItemsSource="{Binding Horizontals}"
SelectedItem="{Binding Horizontal}"
HorizontalAlignment="Stretch" />
</u:FormItem>
<u:FormItem Label="垂直方向">
<ComboBox ItemsSource="{Binding Verticals}"
SelectedItem="{Binding Vertical}"
HorizontalAlignment="Stretch" />
</u:FormItem>
<u:FormItem Label="边缘留白(DIP">
<u:NumericIntUpDown Value="{Binding Margin}" Minimum="0" Maximum="200" Step="2" />
</u:FormItem>
<u:FormItem Label="不透明度">
<u:NumericDoubleUpDown Value="{Binding Opacity}" Minimum="0.3" Maximum="1.0" Step="0.05" />
</u:FormItem>
<u:FormItem Label="最多同时显示">
<u:NumericIntUpDown Value="{Binding MaxVisible}" Minimum="1" Maximum="10" />
</u:FormItem>
<u:FormItem Label="宽度(DIP">
<u:NumericDoubleUpDown Value="{Binding Width}" Minimum="240" Maximum="600" Step="10" />
</u:FormItem>
<u:FormItem Label="淡入淡出(毫秒)">
<u:NumericIntUpDown Value="{Binding FadeMilliseconds}" Minimum="0" Maximum="2000" Step="50" />
</u:FormItem>
<u:FormItem Label="播放提示音">
<ToggleSwitch IsChecked="{Binding PlaySound}" />
</u:FormItem>
<u:FormItem Label="跨所有桌面显示">
<ToggleSwitch IsChecked="{Binding ShowOnAllDesktops}" />
</u:FormItem>
</u:Form>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock Text="{Binding StatusText}"
VerticalAlignment="Center"
Foreground="#FF4CAF50" />
<Button Content="测试弹窗" Command="{Binding TestToastCommand}" />
<Button Content="保存"
Classes="Primary"
Command="{Binding SaveCommand}" />
</StackPanel>
</StackPanel>
</Window>
+8
View File
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace Notify.Views;
public partial class SettingsWindow : Window
{
public SettingsWindow() => InitializeComponent();
}
+59
View File
@@ -0,0 +1,59 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Notify.ViewModels"
x:Class="Notify.Views.ToastWindow"
x:DataType="vm:ToastViewModel"
Width="340"
Height="92"
CanResize="False"
ShowInTaskbar="False"
ShowActivated="False"
Topmost="True"
WindowDecorations="None"
Background="Transparent"
TransparencyLevelHint="Transparent">
<Border x:Name="Root"
Background="#FF2B2B2B"
CornerRadius="10"
BorderThickness="2"
BorderBrush="#FF4B64B2"
BoxShadow="0 6 24 0 #80000000"
Padding="14"
PointerEntered="OnPointerEntered"
PointerExited="OnPointerExited"
PointerPressed="OnBodyPressed">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<Image Grid.Column="0"
x:Name="IconImage"
Width="44" Height="44"
VerticalAlignment="Center"
Source="/Assets/claude.ico" />
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{Binding Title}"
FontWeight="Bold"
FontSize="14"
Foreground="#FFFFFFFF"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Message}"
FontSize="12"
Foreground="#FFCCCCCC"
TextWrapping="Wrap"
MaxLines="2"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<Button Grid.Column="2"
Content="✕"
VerticalAlignment="Top"
Padding="6,2"
FontSize="12"
Foreground="#FF888888"
Background="Transparent"
BorderThickness="0"
Click="OnCloseClick" />
</Grid>
</Border>
</Window>
+197
View File
@@ -0,0 +1,197 @@
using System;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using Notify.Models;
using Notify.ViewModels;
namespace Notify.Views;
public partial class ToastWindow : Window
{
private static readonly Color BorderNormal = Color.Parse("#FF4B64B2");
private static readonly Color BorderInput = Color.Parse("#FF00CFCF");
private readonly ToastSettings _settings;
private readonly DispatcherTimer _dismissTimer;
private readonly bool _sticky;
private readonly long _targetHwnd;
private readonly string? _wtRuntimeId;
private Avalonia.Media.Imaging.Bitmap? _appIcon;
private bool _closing;
// 设计器需要的无参构造
public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null, null, null)
{
}
public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId, string? iconPath, int? durationOverride)
{
_settings = settings;
_targetHwnd = targetHwnd;
_wtRuntimeId = wtRuntimeId;
var duration = durationOverride ?? settings.DurationSeconds;
// 常驻:请求显式 Sticky,或停留时长 <= 0
_sticky = sticky || duration <= 0;
InitializeComponent();
DataContext = vm;
Width = settings.Width;
Opacity = 0;
// Opacity 过渡用于淡入/淡出
Transitions =
[
new DoubleTransition
{
Property = OpacityProperty,
Duration = TimeSpan.FromMilliseconds(settings.FadeMilliseconds),
Easing = new Avalonia.Animation.Easings.CubicEaseOut(),
},
];
Root.BorderBrush = new SolidColorBrush(vm.InputMode ? BorderInput : BorderNormal);
// 调用方 App 图标,取不到则保留默认 Claude 图标
if (!string.IsNullOrEmpty(iconPath))
{
_appIcon = Notify.Interop.AppIcon.Extract(iconPath);
if (_appIcon is not null)
{
IconImage.Source = _appIcon;
}
}
_dismissTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(Math.Max(1, duration)),
};
_dismissTimer.Tick += (_, _) => BeginClose();
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
_appIcon?.Dispose();
_appIcon = null;
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
Opacity = _settings.Opacity; // 触发淡入
if (TryGetPlatformHandle()?.Handle is { } hwnd)
{
// 工具窗口:从任务栏与 Alt+Tab 中隐藏
Notify.Interop.Win32.MakeToolWindow(hwnd);
// 跨虚拟桌面:把窗口钉到所有桌面(失败自动忽略)
if (_settings.ShowOnAllDesktops)
{
TryPinWithRetry(hwnd);
}
}
if (!_sticky)
{
_dismissTimer.Start();
}
}
/// <summary>
/// 窗口刚打开时 shell 可能还没给它登记 ApplicationViewGetViewForHwnd 报
/// TYPE_E_ELEMENTNOTFOUND),故短间隔重试若干次直到成功
/// </summary>
private void TryPinWithRetry(IntPtr hwnd)
{
if (Notify.Interop.VirtualDesktopPinner.TryPin(hwnd))
{
return;
}
var attempts = 0;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(60) };
timer.Tick += (_, _) =>
{
attempts++;
if (_closing || Notify.Interop.VirtualDesktopPinner.TryPin(hwnd) || attempts >= 15)
{
timer.Stop();
}
};
timer.Start();
}
private void OnPointerEntered(object? sender, PointerEventArgs e)
{
// 悬停时暂停自动消失
_dismissTimer.Stop();
if (!_closing)
{
Opacity = _settings.Opacity;
}
}
private void OnPointerExited(object? sender, PointerEventArgs e)
{
if (!_closing && !_sticky)
{
_dismissTimer.Start();
}
}
private void OnBodyPressed(object? sender, PointerPressedEventArgs e)
{
// 左键点击主体:激活原窗口后关闭
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
if (_targetHwnd != 0)
{
var target = new IntPtr(_targetHwnd);
Notify.Interop.WindowActivator.Activate(target);
// 目标是 Windows Terminal 则切回原标签
if (!string.IsNullOrEmpty(_wtRuntimeId))
{
Notify.Interop.WinTerminalTabs.SelectTab(target, _wtRuntimeId);
}
}
BeginClose();
}
}
private void OnCloseClick(object? sender, RoutedEventArgs e) => BeginClose();
/// <summary>
/// 淡出后再真正关闭
/// </summary>
private void BeginClose()
{
if (_closing)
{
return;
}
_closing = true;
_dismissTimer.Stop();
Opacity = 0;
var closeTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(_settings.FadeMilliseconds),
};
closeTimer.Tick += (_, _) =>
{
closeTimer.Stop();
Close();
};
closeTimer.Start();
}
}