feat: 定位模型重做、聚焦短显、托盘交互与 host spawn 修复

- 定位改为 水平(左/中/右) × 垂直(上/中/下) + 边缘留白,取代四角枚举
- 完成类通知在目标窗口已聚焦时用更短停留(FocusedDurationSeconds)
- 托盘左键单击直接打开设置,右键菜单仅保留退出
- 修复 host 拉起用 UseShellExecute=true,脱离钩子 stdout 管道,避免 Claude 卡在 running stop hook
This commit is contained in:
2026-06-22 16:40:27 +08:00
Unverified
parent 6551c65bb8
commit b89223fa54
9 changed files with 119 additions and 51 deletions
+3 -4
View File
@@ -10,12 +10,11 @@
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/claude.ico" ToolTipText="Claude Code Notify">
<TrayIcon Icon="/Assets/claude.ico"
ToolTipText="Claude Code Notify"
Clicked="OnTrayClicked">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="测试弹窗" Click="OnTestToastClick" />
<NativeMenuItem Header="设置…" Click="OnOpenSettingsClick" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="退出" Click="OnExitClick" />
</NativeMenu>
</TrayIcon.Menu>
+12 -16
View File
@@ -57,6 +57,14 @@ public partial class App : Application
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,
@@ -66,6 +74,7 @@ public partial class App : Application
TargetHwnd = message.TargetHwnd,
WtRuntimeId = message.WtRuntimeId,
IconPath = message.IconPath,
DurationSecondsOverride = durationOverride,
}));
}
@@ -83,23 +92,10 @@ public partial class App : Application
});
}
private int _counter;
// 左键单击托盘图标直接打开设置
private void OnTrayClicked(object? sender, EventArgs e) => OpenSettings();
private void OnTestToastClick(object? sender, EventArgs e)
{
_counter++;
var inputMode = _counter % 2 == 0;
Toasts.Show(new ToastRequest
{
Title = inputMode ? "需要你的输入" : "Claude Code",
Message = inputMode
? $"权限请求 #{_counter} — 点击跳回终端"
: $"任务已完成 #{_counter}",
InputMode = inputMode,
});
}
private void OnOpenSettingsClick(object? sender, EventArgs e)
private void OpenSettings()
{
if (_settingsWindow is { } w)
{
+5 -2
View File
@@ -63,12 +63,15 @@ public static class NotificationSpool
return;
}
// 必须用 UseShellExecute=true 让 host 彻底脱离本进程的标准句柄
// 否则常驻 host 会继承并攥住钩子的 stdout 管道,导致 Claude Code
// 等不到管道 EOF 而卡在 "running stop hook"
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "host",
UseShellExecute = false,
CreateNoWindow = true,
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden,
});
}
catch
+5
View File
@@ -33,4 +33,9 @@ public sealed class ToastRequest
/// 调用方 App 的 exe 路径,用于显示其图标
/// </summary>
public string? IconPath { get; init; }
/// <summary>
/// 覆盖停留秒数;为 null 时用设置里的默认值
/// </summary>
public int? DurationSecondsOverride { get; init; }
}
+32 -8
View File
@@ -1,14 +1,23 @@
namespace Notify.Models;
/// <summary>
/// 屏幕角落,决定 toast 堆叠的起点与方向
/// 水平方向:决定 toast 贴左/居中/贴右
/// </summary>
public enum ToastCorner
public enum HEdge
{
TopLeft,
TopRight,
BottomLeft,
BottomRight,
Left,
Center,
Right,
}
/// <summary>
/// 垂直方向:决定 toast 贴上/居中/贴下,并决定堆叠方向
/// </summary>
public enum VEdge
{
Top,
Center,
Bottom,
}
/// <summary>
@@ -22,9 +31,24 @@ public sealed class ToastSettings
public int DurationSeconds { get; set; } = 4;
/// <summary>
/// 出现的屏幕角落
/// 目标窗口已在前台时的停留秒数(你正盯着看,弹一下即可)
/// </summary>
public ToastCorner Corner { get; set; } = ToastCorner.BottomRight;
public int FocusedDurationSeconds { get; set; } = 2;
/// <summary>
/// 水平方向
/// </summary>
public HEdge Horizontal { get; set; } = HEdge.Right;
/// <summary>
/// 垂直方向
/// </summary>
public VEdge Vertical { get; set; } = VEdge.Bottom;
/// <summary>
/// 与屏幕边缘的留白(DIP
/// </summary>
public int Margin { get; set; } = 12;
/// <summary>
/// 不透明度 01
+18 -8
View File
@@ -13,7 +13,6 @@ namespace Notify.Services;
/// </summary>
public sealed class ToastManager
{
private const int Margin = 12;
private const int Gap = 8;
private readonly SettingsService _settings;
@@ -33,7 +32,7 @@ public sealed class ToastManager
}
var vm = new ToastViewModel(request);
var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId, request.IconPath);
var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId, request.IconPath, request.DurationSecondsOverride);
window.Closed += OnToastClosed;
_active.Add(window);
@@ -78,21 +77,32 @@ public sealed class ToastManager
var settings = _settings.Current;
var wa = screen.WorkingArea; // 物理像素
var scale = anchor.RenderScaling;
var margin = (int)(Margin * scale);
var margin = (int)(settings.Margin * scale);
var gap = (int)(Gap * scale);
var bottom = settings.Corner is ToastCorner.BottomLeft or ToastCorner.BottomRight;
var right = settings.Corner is ToastCorner.TopRight or ToastCorner.BottomRight;
var cursor = bottom ? wa.Bottom - margin : wa.Y + margin;
// 垂直靠下时向上堆叠,否则从锚点向下堆叠
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 = right ? wa.Right - margin - wPx : wa.X + margin;
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 (bottom)
if (stackUp)
{
cursor -= hPx;
y = cursor;
+21 -5
View File
@@ -18,7 +18,10 @@ public partial class SettingsViewModel : ObservableObject
var s = settings.Current;
DurationSeconds = s.DurationSeconds;
Corner = s.Corner;
FocusedDurationSeconds = s.FocusedDurationSeconds;
Horizontal = s.Horizontal;
Vertical = s.Vertical;
Margin = s.Margin;
Opacity = s.Opacity;
MaxVisible = s.MaxVisible;
PlaySound = s.PlaySound;
@@ -27,14 +30,24 @@ public partial class SettingsViewModel : ObservableObject
ShowOnAllDesktops = s.ShowOnAllDesktops;
}
public IReadOnlyList<ToastCorner> Corners { get; } =
[ToastCorner.TopLeft, ToastCorner.TopRight, ToastCorner.BottomLeft, ToastCorner.BottomRight];
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 ToastCorner Corner { get; set; }
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; }
@@ -63,7 +76,10 @@ public partial class SettingsViewModel : ObservableObject
_settings.Save(new ToastSettings
{
DurationSeconds = DurationSeconds,
Corner = Corner,
FocusedDurationSeconds = FocusedDurationSeconds,
Horizontal = Horizontal,
Vertical = Vertical,
Margin = Margin,
Opacity = Opacity,
MaxVisible = MaxVisible,
PlaySound = PlaySound,
+17 -3
View File
@@ -24,12 +24,26 @@
<u:NumericIntUpDown Value="{Binding DurationSeconds}" Minimum="1" Maximum="60" />
</u:FormItem>
<u:FormItem Label="出现角落">
<ComboBox ItemsSource="{Binding Corners}"
SelectedItem="{Binding Corner}"
<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>
+6 -5
View File
@@ -26,17 +26,18 @@ public partial class ToastWindow : Window
private bool _closing;
// 设计器需要的无参构造
public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null, null)
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)
public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId, string? iconPath, int? durationOverride)
{
_settings = settings;
_targetHwnd = targetHwnd;
_wtRuntimeId = wtRuntimeId;
// 常驻:请求显式 Sticky,或全局停留时长 <= 0
_sticky = sticky || settings.DurationSeconds <= 0;
var duration = durationOverride ?? settings.DurationSeconds;
// 常驻:请求显式 Sticky,或停留时长 <= 0
_sticky = sticky || duration <= 0;
InitializeComponent();
DataContext = vm;
@@ -68,7 +69,7 @@ public partial class ToastWindow : Window
_dismissTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(Math.Max(1, settings.DurationSeconds)),
Interval = TimeSpan.FromSeconds(Math.Max(1, duration)),
};
_dismissTimer.Tick += (_, _) => BeginClose();
}