feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user