commit 5ce2c8a982d9418f2933d15eea2603564e82f7f7 Author: chuan Date: Mon Jun 22 18:05:15 2026 +0800 feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12) 为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。 diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..97b9a8b --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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" + } + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..37ae744 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a79f0d2 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e5e1d5b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# 批处理脚本必须用 CRLF,否则 cmd 解析会出错 +*.bat text eol=crlf +*.cmd text eol=crlf +# shell 脚本必须用 LF +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..154e127 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e87b589 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc9d1d1 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Notify/App.axaml b/Notify/App.axaml new file mode 100644 index 0000000..2c78b80 --- /dev/null +++ b/Notify/App.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Notify/App.axaml.cs b/Notify/App.axaml.cs new file mode 100644 index 0000000..9366506 --- /dev/null +++ b/Notify/App.axaml.cs @@ -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(); + } + } +} diff --git a/Notify/Assets/JetBrainsMono-ExtraBold.ttf b/Notify/Assets/JetBrainsMono-ExtraBold.ttf new file mode 100644 index 0000000..435d7a7 Binary files /dev/null and b/Notify/Assets/JetBrainsMono-ExtraBold.ttf differ diff --git a/Notify/Assets/claude.ico b/Notify/Assets/claude.ico new file mode 100644 index 0000000..5df0a49 Binary files /dev/null and b/Notify/Assets/claude.ico differ diff --git a/Notify/Assets/notification.wav b/Notify/Assets/notification.wav new file mode 100644 index 0000000..174122a Binary files /dev/null and b/Notify/Assets/notification.wav differ diff --git a/Notify/Cli/CliRunner.cs b/Notify/Cli/CliRunner.cs new file mode 100644 index 0000000..da45c02 --- /dev/null +++ b/Notify/Cli/CliRunner.cs @@ -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; + +/// +/// 钩子子命令实现:纯互操作 / IPC,绝不加载 Avalonia,做完即退出 +/// +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; + } + } +} diff --git a/Notify/Cli/HookInput.cs b/Notify/Cli/HookInput.cs new file mode 100644 index 0000000..306a137 --- /dev/null +++ b/Notify/Cli/HookInput.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Notify.Cli; + +/// +/// Claude Code 钩子经 stdin 传入的 JSON +/// +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; } +} diff --git a/Notify/Interop/AppIcon.cs b/Notify/Interop/AppIcon.cs new file mode 100644 index 0000000..a38977f --- /dev/null +++ b/Notify/Interop/AppIcon.cs @@ -0,0 +1,202 @@ +using System; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace Notify.Interop; + +/// +/// 从 exe 提取图标并转成 Avalonia 位图 +/// +/// ExtractIconEx 拿 HICON,再用 GDI 读出 BGRA 像素构造 Bitmap;不依赖 +/// System.Drawing(其 AOT 不友好) +/// +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(), 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(), + 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; +} diff --git a/Notify/Interop/ProcessTree.cs b/Notify/Interop/ProcessTree.cs new file mode 100644 index 0000000..e62834d --- /dev/null +++ b/Notify/Interop/ProcessTree.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Notify.Interop; + +/// +/// 沿父进程上溯,跳过 shell/运行时,找到真正的调用方 App(编辑器/终端) +/// +internal static partial class ProcessTree +{ + // 这些进程是 shell / 运行时 / 包装器,不是用户面对的 App,跳过继续上溯 + private static readonly HashSet 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 BuildParentMap() + { + var map = new Dictionary(); + 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(); + + 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]; +} diff --git a/Notify/Interop/Sound.cs b/Notify/Interop/Sound.cs new file mode 100644 index 0000000..c546742 --- /dev/null +++ b/Notify/Interop/Sound.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Avalonia.Platform; + +namespace Notify.Interop; + +/// +/// 播放打包的提示音 wav +/// +/// 用 winmm 的 PlaySound 从内存异步播放;为配合 SND_ASYNC,wav 拷到不会被 GC +/// 移动的非托管内存里常驻 +/// +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); + } +} diff --git a/Notify/Interop/VirtualDesktopPinner.cs b/Notify/Interop/VirtualDesktopPinner.cs new file mode 100644 index 0000000..f7483d8 --- /dev/null +++ b/Notify/Interop/VirtualDesktopPinner.cs @@ -0,0 +1,209 @@ +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Notify.Interop; + +/// +/// 把窗口"钉"到所有虚拟桌面(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 下内置封送会被裁剪) +/// +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; + + /// + /// 最近一次失败的诊断信息(临时排查用) + /// + public static string LastError { get; private set; } = ""; + + /// + /// 尝试把指定窗口钉到所有桌面;返回是否成功 + /// + 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(viewsPtr, "IApplicationViewCollection"); + + var pinnedGuid = IID_IVirtualDesktopPinnedApps; + var clsidPinned = CLSID_VirtualDesktopPinnedApps; + var pinnedPtr = shell.QueryService(ref clsidPinned, ref pinnedGuid); + _pinned = WrapRequired(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(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); +} diff --git a/Notify/Interop/Win32.cs b/Notify/Interop/Win32.cs new file mode 100644 index 0000000..e3d1c85 --- /dev/null +++ b/Notify/Interop/Win32.cs @@ -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; +} diff --git a/Notify/Interop/WinTerminalTabs.cs b/Notify/Interop/WinTerminalTabs.cs new file mode 100644 index 0000000..0636f1a --- /dev/null +++ b/Notify/Interop/WinTerminalTabs.cs @@ -0,0 +1,299 @@ +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Text; + +namespace Notify.Interop; + +/// +/// Windows Terminal 标签页的捕获与切换 +/// +/// save 时记录当前选中标签的 RuntimeId,点击通知激活窗口后再据此切回该标签 +/// 全程走源生成 COM(GeneratedComInterface),保证 NativeAOT 兼容 +/// vtable 顺序与 GUID 均取自 Windows SDK UIAutomationClient.h +/// +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? 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(); +} diff --git a/Notify/Interop/WindowActivator.cs b/Notify/Interop/WindowActivator.cs new file mode 100644 index 0000000..d39590e --- /dev/null +++ b/Notify/Interop/WindowActivator.cs @@ -0,0 +1,72 @@ +using System; + +namespace Notify.Interop; + +/// +/// 把目标窗口拉回前台 +/// +/// Windows 限制后台进程抢焦点,这里用一套组合技绕过:ALT 键模拟 + +/// AttachThreadInput 把当前线程与前台/目标线程的输入队列挂接 + +/// SetWindowPos/BringWindowToTop/SwitchToThisWindow/SetForegroundWindow 多管齐下 +/// +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; + } + } +} diff --git a/Notify/Ipc/IpcConstants.cs b/Notify/Ipc/IpcConstants.cs new file mode 100644 index 0000000..0fafb53 --- /dev/null +++ b/Notify/Ipc/IpcConstants.cs @@ -0,0 +1,7 @@ +namespace Notify.Ipc; + +internal static class IpcConstants +{ + // 保证 Host 单例的互斥量名(Local 级,按用户会话隔离) + public const string HostMutexName = "ClaudeCodeNotifyHost"; +} diff --git a/Notify/Ipc/NotificationSpool.cs b/Notify/Ipc/NotificationSpool.cs new file mode 100644 index 0000000..3409150 --- /dev/null +++ b/Notify/Ipc/NotificationSpool.cs @@ -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; + +/// +/// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费 +/// +/// 取代命名管道,避免 CLI 在 Host 冷启动时被阻塞而拖住 Claude Code +/// +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 handler) + { + try + { + if (!Directory.Exists(Dir)) + { + return; + } + + foreach (var path in Directory.GetFiles(Dir, "*.json")) + { + if (ReadAndRemove(path) is { } msg) + { + handler(msg); + } + } + } + catch + { + // 忽略 + } + } +} diff --git a/Notify/Ipc/NotifyMessage.cs b/Notify/Ipc/NotifyMessage.cs new file mode 100644 index 0000000..9146160 --- /dev/null +++ b/Notify/Ipc/NotifyMessage.cs @@ -0,0 +1,29 @@ +namespace Notify.Ipc; + +/// +/// 一条投递给 Host 的弹窗请求,经 spool 文件传递 +/// +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; } +} diff --git a/Notify/Ipc/SpoolWatcher.cs b/Notify/Ipc/SpoolWatcher.cs new file mode 100644 index 0000000..8580fe2 --- /dev/null +++ b/Notify/Ipc/SpoolWatcher.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; + +namespace Notify.Ipc; + +/// +/// Host 侧:监视 spool 目录,新文件出现即消费并回调 +/// +public sealed class SpoolWatcher +{ + private readonly Action _onMessage; + private FileSystemWatcher? _watcher; + + public SpoolWatcher(Action 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); + } + } +} diff --git a/Notify/Models/StateData.cs b/Notify/Models/StateData.cs new file mode 100644 index 0000000..460a672 --- /dev/null +++ b/Notify/Models/StateData.cs @@ -0,0 +1,19 @@ +namespace Notify.Models; + +/// +/// 每会话持久化的状态,由 save 钩子写入、notify 钩子与点击激活读取 +/// +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; } = ""; +} diff --git a/Notify/Models/ToastRequest.cs b/Notify/Models/ToastRequest.cs new file mode 100644 index 0000000..7c79e4c --- /dev/null +++ b/Notify/Models/ToastRequest.cs @@ -0,0 +1,41 @@ +namespace Notify.Models; + +/// +/// 一次弹窗请求(后续由 hook / named pipe 投递) +/// +public sealed class ToastRequest +{ + public required string Title { get; init; } + + public required string Message { get; init; } + + /// + /// true = 需要输入(黄色边框),false = 任务完成(橙色边框) + /// + public bool InputMode { get; init; } + + /// + /// true = 常驻:不自动消失,只能点击 / ✕ 关闭 + /// + public bool Sticky { get; init; } + + /// + /// 点击 toast 主体时要激活的窗口句柄,0 表示不激活 + /// + public long TargetHwnd { get; init; } + + /// + /// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId + /// + public string? WtRuntimeId { get; init; } + + /// + /// 调用方 App 的 exe 路径,用于显示其图标 + /// + public string? IconPath { get; init; } + + /// + /// 覆盖停留秒数;为 null 时用设置里的默认值 + /// + public int? DurationSecondsOverride { get; init; } +} diff --git a/Notify/Models/ToastSettings.cs b/Notify/Models/ToastSettings.cs new file mode 100644 index 0000000..de2456f --- /dev/null +++ b/Notify/Models/ToastSettings.cs @@ -0,0 +1,84 @@ +namespace Notify.Models; + +/// +/// 水平方向:决定 toast 贴左/居中/贴右 +/// +public enum HEdge +{ + Left, + Center, + Right, +} + +/// +/// 垂直方向:决定 toast 贴上/居中/贴下,并决定堆叠方向 +/// +public enum VEdge +{ + Top, + Center, + Bottom, +} + +/// +/// 持久化的弹窗设置(纯数据模型,序列化到磁盘) +/// +public sealed class ToastSettings +{ + /// + /// 自动消失前的停留秒数 + /// + public int DurationSeconds { get; set; } = 4; + + /// + /// 目标窗口已在前台时的停留秒数(你正盯着看,弹一下即可) + /// + public int FocusedDurationSeconds { get; set; } = 2; + + /// + /// 水平方向 + /// + public HEdge Horizontal { get; set; } = HEdge.Right; + + /// + /// 垂直方向 + /// + public VEdge Vertical { get; set; } = VEdge.Bottom; + + /// + /// 与屏幕边缘的留白(DIP) + /// + public int Margin { get; set; } = 12; + + /// + /// 不透明度 0–1 + /// + public double Opacity { get; set; } = 0.96; + + /// + /// 最多同时可见的 toast 数量,超出则排队 + /// + public int MaxVisible { get; set; } = 5; + + /// + /// 是否播放提示音 + /// + public bool PlaySound { get; set; } = true; + + /// + /// toast 宽度(DIP) + /// + public double Width { get; set; } = 340; + + /// + /// 淡入/淡出时长(毫秒) + /// + public int FadeMilliseconds { get; set; } = 300; + + /// + /// 跨所有虚拟桌面显示(未公开 API,失败自动退回单桌面) + /// + public bool ShowOnAllDesktops { get; set; } = true; + + public ToastSettings Clone() => (ToastSettings)MemberwiseClone(); +} diff --git a/Notify/Notify.csproj b/Notify/Notify.csproj new file mode 100644 index 0000000..9570b4c --- /dev/null +++ b/Notify/Notify.csproj @@ -0,0 +1,82 @@ + + + + WinExe + net10.0-windows + enable + latest + true + app.manifest + true + Assets\claude.ico + Notify + notify + 1.0.0 + true + true + + + + + win-x64 + true + false + Size + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_AotJunk Include="$(PublishDir)*.dll" /> + <_AotJunk Include="$(PublishDir)*.lib" /> + <_AotJunk Include="$(PublishDir)*.pdb" /> + + + + + diff --git a/Notify/Program.cs b/Notify/Program.cs new file mode 100644 index 0000000..ab6e241 --- /dev/null +++ b/Notify/Program.cs @@ -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() + .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; + } +} diff --git a/Notify/Serialization/AppJsonContext.cs b/Notify/Serialization/AppJsonContext.cs new file mode 100644 index 0000000..0c7eed1 --- /dev/null +++ b/Notify/Serialization/AppJsonContext.cs @@ -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; diff --git a/Notify/Services/SettingsService.cs b/Notify/Services/SettingsService.cs new file mode 100644 index 0000000..169f5fc --- /dev/null +++ b/Notify/Services/SettingsService.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Text.Json; +using Notify.Models; +using Notify.Serialization; + +namespace Notify.Services; + +/// +/// 加载/保存弹窗设置,并在变更时通知订阅者 +/// +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(); + + /// + /// 设置被保存后触发 + /// + public event Action? 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); + } +} diff --git a/Notify/Services/StateStore.cs b/Notify/Services/StateStore.cs new file mode 100644 index 0000000..51efad5 --- /dev/null +++ b/Notify/Services/StateStore.cs @@ -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; + +/// +/// 每会话状态文件的读写,位于 %TEMP%\claude-notify-{session_id}.json +/// +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()); +} diff --git a/Notify/Services/ToastManager.cs b/Notify/Services/ToastManager.cs new file mode 100644 index 0000000..4fe0a76 --- /dev/null +++ b/Notify/Services/ToastManager.cs @@ -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; + +/// +/// 在常驻进程内管理所有 toast 窗口:创建、按角落堆叠、关闭后重新排布 +/// 这是 Rust 版"每条通知一进程 + EnumWindows"的替代——进程内一个列表即可 +/// +public sealed class ToastManager +{ + private const int Gap = 8; + + private readonly SettingsService _settings; + private readonly List _active = []; + private readonly Queue _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()); + } + } + + /// + /// 把所有活动 toast 从指定角落沿垂直方向依次堆叠 + /// + 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); + } + } +} diff --git a/Notify/ViewModels/SettingsViewModel.cs b/Notify/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..1ec8c6d --- /dev/null +++ b/Notify/ViewModels/SettingsViewModel.cs @@ -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 Horizontals { get; } = [HEdge.Left, HEdge.Center, HEdge.Right]; + + public IReadOnlyList 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, + }); + } +} diff --git a/Notify/ViewModels/ToastViewModel.cs b/Notify/ViewModels/ToastViewModel.cs new file mode 100644 index 0000000..056339d --- /dev/null +++ b/Notify/ViewModels/ToastViewModel.cs @@ -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; } +} diff --git a/Notify/Views/SettingsWindow.axaml b/Notify/Views/SettingsWindow.axaml new file mode 100644 index 0000000..bb182bd --- /dev/null +++ b/Notify/Views/SettingsWindow.axaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +