feat: WT 切标签、工具窗口化与非阻塞投递

- P1b: 点击通知切回 Windows Terminal 原标签(源生成 COM UIAutomation,AOT 友好)
- Toast 设为工具窗口(WS_EX_TOOLWINDOW),从任务栏与 Alt+Tab 隐藏
- 投递改为落盘队列 spool + FileSystemWatcher,CLI 毫秒级返回不阻塞 Claude Code
- 移除命名管道(PipeServer/PipeClient/PipeMessage),新增 NotificationSpool/SpoolWatcher
- NativeAOT 发布配置 + scripts/build.bat(vcvars 自动配置 MSVC 工具链)
- .gitattributes 保证 .bat 用 CRLF
This commit is contained in:
2026-06-22 15:47:08 +08:00
Unverified
parent 13fe87da99
commit 891128fec3
19 changed files with 617 additions and 161 deletions
+3
View File
@@ -0,0 +1,3 @@
# 批处理脚本必须用 CRLF,否则 cmd 解析会出错
*.bat text eol=crlf
*.cmd text eol=crlf
+7 -6
View File
@@ -15,7 +15,7 @@ namespace Notify;
public partial class App : Application
{
private SettingsWindow? _settingsWindow;
private PipeServer? _pipeServer;
private SpoolWatcher? _spoolWatcher;
public static new App Current => (App)Application.Current!;
@@ -30,9 +30,9 @@ public partial class App : Application
Settings.Load();
Toasts = new ToastManager(Settings);
// 监听命名管道,把瘦客户端投递的请求转成 toast
_pipeServer = new PipeServer(OnPipeMessage);
_pipeServer.Start();
// 监视 spool 目录,把瘦客户端投递的请求转成 toast
_spoolWatcher = new SpoolWatcher(OnNotify);
_spoolWatcher.Start();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
@@ -49,8 +49,8 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
// 管道线程收到请求,切回 UI 线程弹出 toast
private void OnPipeMessage(PipeMessage message)
// 监视线程收到请求,切回 UI 线程弹出 toast
private void OnNotify(NotifyMessage message)
{
Dispatcher.UIThread.Post(() => Toasts.Show(new ToastRequest
{
@@ -59,6 +59,7 @@ public partial class App : Application
InputMode = message.InputMode,
Sticky = message.Sticky,
TargetHwnd = message.TargetHwnd,
WtRuntimeId = message.WtRuntimeId,
}));
}
+40 -4
View File
@@ -25,10 +25,17 @@ public static class CliRunner
}
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,
});
return 0;
}
@@ -45,7 +52,7 @@ public static class CliRunner
var state = StateStore.Load(input.SessionId);
var message = !string.IsNullOrWhiteSpace(state?.Prompt) ? state!.Prompt : "Task completed";
return SendToHost(new PipeMessage
NotificationSpool.Deliver(new NotifyMessage
{
SessionId = input.SessionId,
Title = "Claude Code",
@@ -53,7 +60,9 @@ public static class CliRunner
InputMode = false,
Sticky = false,
TargetHwnd = state?.Hwnd ?? 0,
WtRuntimeId = state?.WtRuntimeId,
});
return 0;
}
// Notification / PreToolUse:需要输入,常驻显示
@@ -74,7 +83,7 @@ public static class CliRunner
var (title, message) = Resolve(input);
var state = StateStore.Load(input.SessionId);
return SendToHost(new PipeMessage
NotificationSpool.Deliver(new NotifyMessage
{
SessionId = input.SessionId,
Title = title,
@@ -82,7 +91,9 @@ public static class CliRunner
InputMode = true,
Sticky = true,
TargetHwnd = state?.Hwnd ?? 0,
WtRuntimeId = state?.WtRuntimeId,
});
return 0;
}
// 仅用于自测:acttest <hwnd>,尝试激活指定窗口并把结果写入临时日志
@@ -111,6 +122,33 @@ public static class CliRunner
return ok ? 0 : 1;
}
// 仅用于自测:wttest <hwnd> <runtimeId>,切到该标签后回读当前选中项
public static int WtTest(string[] args)
{
if (args.Length < 3 || !long.TryParse(args[1], out var h))
{
return 1;
}
var hwnd = new IntPtr(h);
var ok = WinTerminalTabs.SelectTab(hwnd, args[2]);
System.Threading.Thread.Sleep(250);
var now = WinTerminalTabs.GetSelectedTabRuntimeId(hwnd);
try
{
System.IO.File.WriteAllText(
System.IO.Path.Combine(System.IO.Path.GetTempPath(), "notify-wttest.log"),
$"select ok={ok} expected={args[2]} nowSelected={now} match={(now == args[2])}\n");
}
catch
{
// 忽略
}
return ok ? 0 : 1;
}
// SessionEnd:清理会话状态
public static int Cleanup()
{
@@ -148,8 +186,6 @@ public static class CliRunner
return (title, message);
}
private static int SendToHost(PipeMessage message) => PipeClient.Send(message) ? 0 : 1;
// 直接读原始字节并按 UTF-8 解码:WinExe 下 Console.In 不可靠,且其代码页
// 会把中文解成乱码(GBK),这里绕开
private static HookInput? ReadStdin()
+28
View File
@@ -52,6 +52,31 @@ internal static partial class Win32
[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;
@@ -61,4 +86,7 @@ internal static partial class Win32
internal const uint SWP_SHOWWINDOW = 0x0040;
internal const byte VK_MENU = 0x12;
internal const uint KEYEVENTF_KEYUP = 0x0002;
internal const int GWL_EXSTYLE = -20;
internal const long WS_EX_TOOLWINDOW = 0x00000080;
internal const long WS_EX_APPWINDOW = 0x00040000;
}
+299
View File
@@ -0,0 +1,299 @@
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
using System.Text;
namespace Notify.Interop;
/// <summary>
/// Windows Terminal 标签页的捕获与切换
///
/// save 时记录当前选中标签的 RuntimeId,点击通知激活窗口后再据此切回该标签
/// 全程走源生成 COMGeneratedComInterface),保证 NativeAOT 兼容
/// vtable 顺序与 GUID 均取自 Windows SDK UIAutomationClient.h
/// </summary>
public static partial class WinTerminalTabs
{
private const string WtClass = "CASCADIA_HOSTING_WINDOW_CLASS";
private const int TreeScopeDescendants = 4;
private const int ControlTypePropertyId = 30003;
private const int IsSelectedPropertyId = 30079;
private const int TabItemControlTypeId = 50019;
private const int SelectionItemPatternId = 10010;
private const int CLSCTX_INPROC_SERVER = 1;
private static readonly StrategyBasedComWrappers ComWrappers = new();
private static IUIAutomation? _uia;
private static bool _initTried;
public static bool IsWindowsTerminal(IntPtr hwnd) =>
hwnd != IntPtr.Zero && Win32.GetClassNameOf(hwnd) == WtClass;
// 返回当前选中标签的 RuntimeId 串,失败返回空串
public static string GetSelectedTabRuntimeId(IntPtr hwnd)
{
try
{
var uia = EnsureUia();
if (uia is null)
{
return "";
}
var root = uia.ElementFromHandle(hwnd);
var cond = uia.CreateTrueCondition();
var all = root.FindAll(TreeScopeDescendants, cond);
var count = all.Length();
for (var i = 0; i < count; i++)
{
var el = all.GetElement(i);
if (GetIntProperty(el, ControlTypePropertyId) != TabItemControlTypeId)
{
continue;
}
if (GetBoolProperty(el, IsSelectedPropertyId))
{
return RuntimeIdOf(el);
}
}
}
catch
{
// 任何 COM 异常退回空串
}
return "";
}
// 在已激活的 WT 窗口里找到匹配 RuntimeId 的标签并选中
public static bool SelectTab(IntPtr hwnd, string runtimeId)
{
if (string.IsNullOrEmpty(runtimeId))
{
return false;
}
try
{
var uia = EnsureUia();
if (uia is null)
{
return false;
}
var root = uia.ElementFromHandle(hwnd);
var cond = uia.CreateTrueCondition();
var all = root.FindAll(TreeScopeDescendants, cond);
var count = all.Length();
for (var i = 0; i < count; i++)
{
var el = all.GetElement(i);
if (GetIntProperty(el, ControlTypePropertyId) != TabItemControlTypeId)
{
continue;
}
if (RuntimeIdOf(el) != runtimeId)
{
continue;
}
var pattern = el.GetCurrentPattern(SelectionItemPatternId);
if (pattern is null)
{
return false;
}
pattern.Select();
return true;
}
}
catch
{
// 切换失败不影响窗口已被激活
}
return false;
}
private static IUIAutomation? EnsureUia()
{
if (_initTried)
{
return _uia;
}
_initTried = true;
try
{
var clsid = new Guid("ff48dba4-60ef-4201-aa87-54103eef594e");
var iid = typeof(IUIAutomation).GUID;
var hr = CoCreateInstance(ref clsid, IntPtr.Zero, CLSCTX_INPROC_SERVER, ref iid, out var ptr);
if (hr >= 0 && ptr != IntPtr.Zero)
{
_uia = (IUIAutomation)ComWrappers.GetOrCreateObjectForComInstance(ptr, CreateObjectFlags.None);
Marshal.Release(ptr);
}
}
catch
{
_uia = null;
}
return _uia;
}
private static int GetIntProperty(IUIAutomationElement el, int propertyId)
{
var v = el.GetCurrentPropertyValue(propertyId);
return v.lVal;
}
private static bool GetBoolProperty(IUIAutomationElement el, int propertyId)
{
var v = el.GetCurrentPropertyValue(propertyId);
return v.boolVal != 0;
}
// RuntimeId 是一个 int 数组(SAFEARRAY),拼成点分串用于比较
private static string RuntimeIdOf(IUIAutomationElement el)
{
var psa = el.GetRuntimeId();
if (psa == IntPtr.Zero)
{
return "";
}
try
{
if (SafeArrayGetLBound(psa, 1, out var lb) < 0 || SafeArrayGetUBound(psa, 1, out var ub) < 0)
{
return "";
}
var sb = new StringBuilder();
for (var idx = lb; idx <= ub; idx++)
{
var i = idx;
if (SafeArrayGetElement(psa, ref i, out var val) < 0)
{
return "";
}
if (sb.Length > 0)
{
sb.Append('.');
}
sb.Append(val);
}
return sb.ToString();
}
finally
{
SafeArrayDestroy(psa);
}
}
[LibraryImport("ole32.dll")]
private static partial int CoCreateInstance(ref Guid rclsid, IntPtr pUnkOuter, int dwClsContext, ref Guid riid, out IntPtr ppv);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetLBound(IntPtr psa, uint nDim, out int plLbound);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetUBound(IntPtr psa, uint nDim, out int plUbound);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetElement(IntPtr psa, ref int rgIndices, out int pv);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayDestroy(IntPtr psa);
}
// VARIANT 的最小化布局(x64 为 24 字节),只读 VT_I4 / VT_BOOL
[StructLayout(LayoutKind.Explicit, Size = 24)]
internal struct VARIANT
{
[FieldOffset(0)]
public ushort vt;
[FieldOffset(8)]
public int lVal;
[FieldOffset(8)]
public short boolVal;
}
[GeneratedComInterface]
[Guid("30cbe57d-d9d0-452a-ab13-7ac5ac4825ee")]
internal partial interface IUIAutomation
{
void _CompareElements();
void _CompareRuntimeIds();
void _GetRootElement();
IUIAutomationElement ElementFromHandle(IntPtr hwnd);
void _ElementFromPoint();
void _GetFocusedElement();
void _GetRootElementBuildCache();
void _ElementFromHandleBuildCache();
void _ElementFromPointBuildCache();
void _GetFocusedElementBuildCache();
void _CreateTreeWalker();
void _get_ControlViewWalker();
void _get_ContentViewWalker();
void _get_RawViewWalker();
void _get_RawViewCondition();
void _get_ControlViewCondition();
void _get_ContentViewCondition();
void _CreateCacheRequest();
IUIAutomationCondition CreateTrueCondition();
}
[GeneratedComInterface]
[Guid("d22108aa-8ac5-49a5-837b-37bbb3d7591e")]
internal partial interface IUIAutomationElement
{
void _SetFocus();
IntPtr GetRuntimeId();
void _FindFirst();
IUIAutomationElementArray FindAll(int scope, IUIAutomationCondition condition);
void _FindFirstBuildCache();
void _FindAllBuildCache();
void _BuildUpdatedCache();
VARIANT GetCurrentPropertyValue(int propertyId);
void _GetCurrentPropertyValueEx();
void _GetCachedPropertyValue();
void _GetCachedPropertyValueEx();
void _GetCurrentPatternAs();
void _GetCachedPatternAs();
[return: MarshalUsing(typeof(UniqueComInterfaceMarshaller<IUIAutomationSelectionItemPattern>))]
IUIAutomationSelectionItemPattern? GetCurrentPattern(int patternId);
}
[GeneratedComInterface]
[Guid("14314595-b4bc-4055-95f2-58f2e42c9855")]
internal partial interface IUIAutomationElementArray
{
int Length();
IUIAutomationElement GetElement(int index);
}
[GeneratedComInterface]
[Guid("352ffba8-0973-437c-a61f-f64cafd81df9")]
internal partial interface IUIAutomationCondition
{
}
[GeneratedComInterface]
[Guid("a8efa66a-0fda-421a-9194-38021f3578ea")]
internal partial interface IUIAutomationSelectionItemPattern
{
void Select();
}
-3
View File
@@ -2,9 +2,6 @@ namespace Notify.Ipc;
internal static class IpcConstants
{
// 瘦客户端与常驻 Host 之间的命名管道名
public const string PipeName = "claude-code-notify";
// 保证 Host 单例的互斥量名(Local 级,按用户会话隔离)
public const string HostMutexName = "ClaudeCodeNotifyHost";
}
+118
View File
@@ -0,0 +1,118 @@
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;
}
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "host",
UseShellExecute = false,
CreateNoWindow = true,
});
}
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
{
// 忽略
}
}
}
@@ -1,9 +1,9 @@
namespace Notify.Ipc;
/// <summary>
/// 瘦客户端经命名管道投递给 Host 的一条弹窗请求
/// 一条投递给 Host 的弹窗请求,经 spool 文件传递
/// </summary>
public sealed class PipeMessage
public sealed class NotifyMessage
{
public string Title { get; set; } = "";
@@ -15,9 +15,12 @@ public sealed class PipeMessage
// true = 常驻,不自动消失
public bool Sticky { get; set; }
// 触发该通知的会话 id,便于 Host 后续按会话激活窗口
// 触发该通知的会话 id
public string? SessionId { get; set; }
// 点击 toast 时要激活的目标窗口句柄,0 表示无
public long TargetHwnd { get; set; }
// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
public string? WtRuntimeId { get; set; }
}
-79
View File
@@ -1,79 +0,0 @@
using System;
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using System.Threading;
using Notify.Serialization;
namespace Notify.Ipc;
/// <summary>
/// 瘦客户端侧:把一条 PipeMessage 发给 HostHost 不在则拉起后重试
/// </summary>
public static class PipeClient
{
public static bool Send(PipeMessage message)
{
var json = JsonSerializer.Serialize(message, AppJsonContext.Default.PipeMessage);
var bytes = Encoding.UTF8.GetBytes(json);
if (TrySend(bytes, 300))
{
return true;
}
// Host 未运行:拉起后等待其管道就绪再重试,最多约 5 秒
StartHost();
for (var i = 0; i < 50; i++)
{
Thread.Sleep(100);
if (TrySend(bytes, 300))
{
return true;
}
}
return false;
}
private static bool TrySend(byte[] bytes, int timeoutMs)
{
try
{
using var client = new NamedPipeClientStream(".", IpcConstants.PipeName, PipeDirection.Out);
client.Connect(timeoutMs);
client.Write(bytes, 0, bytes.Length);
client.Flush();
return true;
}
catch
{
return false;
}
}
private static void StartHost()
{
try
{
var exe = Environment.ProcessPath;
if (exe is null)
{
return;
}
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "host",
UseShellExecute = false,
CreateNoWindow = true,
});
}
catch
{
// 拉起失败则发送会重试超时后放弃
}
}
}
-58
View File
@@ -1,58 +0,0 @@
using System;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Notify.Serialization;
namespace Notify.Ipc;
/// <summary>
/// Host 侧命名管道监听:每个连接读取一条 PipeMessage 并回调
/// </summary>
public sealed class PipeServer
{
private readonly Action<PipeMessage> _onMessage;
public PipeServer(Action<PipeMessage> onMessage) => _onMessage = onMessage;
public void Start() => Task.Run(RunLoopAsync);
private async Task RunLoopAsync()
{
while (true)
{
try
{
using var server = new NamedPipeServerStream(
IpcConstants.PipeName,
PipeDirection.In,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync().ConfigureAwait(false);
using var ms = new MemoryStream();
await server.CopyToAsync(ms).ConfigureAwait(false);
var json = Encoding.UTF8.GetString(ms.ToArray());
if (string.IsNullOrWhiteSpace(json))
{
continue;
}
var msg = JsonSerializer.Deserialize(json, AppJsonContext.Default.PipeMessage);
if (msg is not null)
{
_onMessage(msg);
}
}
catch
{
// 单个连接出错不影响后续监听
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
using System;
using System.IO;
namespace Notify.Ipc;
/// <summary>
/// Host 侧:监视 spool 目录,新文件出现即消费并回调
/// </summary>
public sealed class SpoolWatcher
{
private readonly Action<NotifyMessage> _onMessage;
private FileSystemWatcher? _watcher;
public SpoolWatcher(Action<NotifyMessage> onMessage) => _onMessage = onMessage;
public void Start()
{
Directory.CreateDirectory(NotificationSpool.Dir);
// 先补弹启动前堆积的请求
NotificationSpool.DrainExisting(_onMessage);
_watcher = new FileSystemWatcher(NotificationSpool.Dir, "*.json")
{
NotifyFilter = NotifyFilters.FileName,
EnableRaisingEvents = true,
};
_watcher.Created += OnCreated;
_watcher.Renamed += OnRenamed;
}
private void OnCreated(object sender, FileSystemEventArgs e) => Handle(e.FullPath);
private void OnRenamed(object sender, RenamedEventArgs e) => Handle(e.FullPath);
private void Handle(string path)
{
if (NotificationSpool.ReadAndRemove(path) is { } msg)
{
_onMessage(msg);
}
}
}
+3
View File
@@ -10,4 +10,7 @@ public sealed class StateData
// 用户当次输入的 prompt,用作"任务完成"通知的正文
public string Prompt { get; set; } = "";
// 若前台是 Windows Terminal,记录当时选中标签的 RuntimeId,用于点击后切回
public string WtRuntimeId { get; set; } = "";
}
+5
View File
@@ -23,4 +23,9 @@ public sealed class ToastRequest
/// 点击 toast 主体时要激活的窗口句柄,0 表示不激活
/// </summary>
public long TargetHwnd { get; init; }
/// <summary>
/// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
/// </summary>
public string? WtRuntimeId { get; init; }
}
+15
View File
@@ -15,10 +15,25 @@
<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" />
+1
View File
@@ -32,6 +32,7 @@ internal static class Program
"input" => CliRunner.Input(),
"cleanup" => CliRunner.Cleanup(),
"acttest" => CliRunner.ActTest(args),
"wttest" => CliRunner.WtTest(args),
_ => RunHost(args),
};
}
+1 -1
View File
@@ -10,5 +10,5 @@ namespace Notify.Serialization;
[JsonSerializable(typeof(ToastSettings))]
[JsonSerializable(typeof(StateData))]
[JsonSerializable(typeof(HookInput))]
[JsonSerializable(typeof(PipeMessage))]
[JsonSerializable(typeof(NotifyMessage))]
internal partial class AppJsonContext : JsonSerializerContext;
+1 -1
View File
@@ -33,7 +33,7 @@ public sealed class ToastManager
}
var vm = new ToastViewModel(request);
var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd);
var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId);
window.Closed += OnToastClosed;
_active.Add(window);
+21 -6
View File
@@ -21,17 +21,19 @@ public partial class ToastWindow : Window
private readonly DispatcherTimer _dismissTimer;
private readonly bool _sticky;
private readonly long _targetHwnd;
private readonly string? _wtRuntimeId;
private bool _closing;
// 设计器需要的无参构造
public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0)
public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null)
{
}
public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd)
public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId)
{
_settings = settings;
_targetHwnd = targetHwnd;
_wtRuntimeId = wtRuntimeId;
// 常驻:请求显式 Sticky,或全局停留时长 <= 0
_sticky = sticky || settings.DurationSeconds <= 0;
InitializeComponent();
@@ -65,10 +67,16 @@ public partial class ToastWindow : Window
base.OnOpened(e);
Opacity = _settings.Opacity; // 触发淡入
// 跨虚拟桌面:把窗口钉到所有桌面(失败自动忽略)
if (_settings.ShowOnAllDesktops && TryGetPlatformHandle()?.Handle is { } hwnd)
if (TryGetPlatformHandle()?.Handle is { } hwnd)
{
TryPinWithRetry(hwnd);
// 工具窗口:从任务栏与 Alt+Tab 中隐藏
Notify.Interop.Win32.MakeToolWindow(hwnd);
// 跨虚拟桌面:把窗口钉到所有桌面(失败自动忽略)
if (_settings.ShowOnAllDesktops)
{
TryPinWithRetry(hwnd);
}
}
if (!_sticky)
@@ -126,7 +134,14 @@ public partial class ToastWindow : Window
{
if (_targetHwnd != 0)
{
Notify.Interop.WindowActivator.Activate(new IntPtr(_targetHwnd));
var target = new IntPtr(_targetHwnd);
Notify.Interop.WindowActivator.Activate(target);
// 目标是 Windows Terminal 则切回原标签
if (!string.IsNullOrEmpty(_wtRuntimeId))
{
Notify.Interop.WinTerminalTabs.SelectTab(target, _wtRuntimeId);
}
}
BeginClose();
+26
View File
@@ -0,0 +1,26 @@
@echo off
setlocal
rem 切到仓库根目录(scripts 的上一级)
cd /d "%~dp0\.."
rem NativeAOT 的原生链接需要 MSVC 工具链,先用 vswhere 找到 VS 并配置环境
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
if exist "%VSWHERE%" (
for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSPATH=%%i"
)
if defined VSPATH if exist "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" (
echo === 配置 MSVC 环境: %VSPATH% ===
call "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" >nul
)
echo === NativeAOT publish (win-x64) -^> bin\notify.exe ===
dotnet publish Notify -c Release -r win-x64 -p:PublishAot=true -o bin
if errorlevel 1 (
echo.
echo *** 发布失败。若提示找不到 link.exe,请从 "Developer Command Prompt for VS" 运行本脚本 ***
exit /b 1
)
echo.
echo === 完成: %CD%\bin\notify.exe ===
endlocal